From e9063f106dc4ffde16f611ff1c237b3757cff3bf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:31:44 -0700 Subject: [PATCH] Move agent-catalog schema migration to shared for CLI Why: CLI's offline settings writes need AGENT_CATALOG_SCHEMA_VERSION but cannot import from out/main (electron-vite removes it during rebuild). Extract pure schema logic to src/shared, FS-bound backup operations to src/main. Add CLI require-resolution verification to catch MODULE_NOT_FOUND at build time instead of runtime. Fix hydration-time purge to defer incomplete repos: was deleting all local owners on remote-only hydration (incident 2026-07-19). --- .../scripts/verify-cli-require-resolution.mjs | 108 ++++++++++++++++++ .../verify-cli-require-resolution.test.mjs | 84 ++++++++++++++ config/tsconfig.cli.json | 1 - package.json | 3 +- src/cli/handlers/agent-hooks.ts | 2 +- ...t-catalog-forward-rollback-fixture.test.ts | 7 +- .../agent-catalog-pre-v1-backup.test.ts | 55 +++++++++ .../agent-catalog-pre-v1-backup.ts | 54 +++++++++ src/main/data-recovery/recovery-points.ts | 2 +- .../loading-store/loaded-state-parsing.ts | 6 +- .../loading-store/profile-preferences.ts | 6 +- .../worktrees/listing/fetch-all-worktrees.ts | 83 +++++++++++++- .../agent-catalog-schema-migration.test.ts | 61 +--------- .../agent-catalog-schema-migration.ts | 56 +-------- .../e2e-completed-onboarding-profile.ts | 2 +- 15 files changed, 401 insertions(+), 129 deletions(-) create mode 100644 config/scripts/verify-cli-require-resolution.mjs create mode 100644 config/scripts/verify-cli-require-resolution.test.mjs create mode 100644 src/main/agent-launch/agent-catalog-pre-v1-backup.test.ts create mode 100644 src/main/agent-launch/agent-catalog-pre-v1-backup.ts rename src/{main/agent-launch => shared}/agent-catalog-schema-migration.test.ts (69%) rename src/{main/agent-launch => shared}/agent-catalog-schema-migration.ts (75%) diff --git a/config/scripts/verify-cli-require-resolution.mjs b/config/scripts/verify-cli-require-resolution.mjs new file mode 100644 index 00000000000..d65b81292bf --- /dev/null +++ b/config/scripts/verify-cli-require-resolution.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +// Why: build:electron-vite cleans out/main and re-emits only its rollup input +// entries, so a CLI import of a main module that lacks a matching entry ships +// as a MODULE_NOT_FOUND crash (1.4.150-rc.1.perf shipped a dead CLI this way). +// This walks the compiled CLI's relative require() graph against the final +// out/ tree so the break fails the build instead of the user's first command. + +const RELATIVE_REQUIRE_PATTERN = /\brequire\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g + +function listJsFilesRecursively(dir) { + const files = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...listJsFilesRecursively(entryPath)) + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(entryPath) + } + } + return files +} + +/** Node CJS resolution for a relative specifier, limited to on-disk shapes tsc + * and rollup emit: exact file, `.js`/`.json` extension, or directory index. */ +function resolveRelativeRequire(fromFile, specifier) { + const base = path.resolve(path.dirname(fromFile), specifier) + const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')] + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate + } + } + return null +} + +/** + * Walks every relative require() reachable from out/cli and asserts each target + * resolves inside the final out/ tree. Returns the list of missing edges. + */ +export function verifyCliRequireResolution({ + projectDir = path.resolve(import.meta.dirname, '..', '..') +} = {}) { + const outDir = path.join(projectDir, 'out') + const cliDir = path.join(outDir, 'cli') + if (!existsSync(cliDir)) { + return { checkedFiles: 0, missing: [], skipped: true } + } + + const queue = listJsFilesRecursively(cliDir) + const visited = new Set(queue) + const missing = [] + while (queue.length > 0) { + const file = queue.pop() + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(RELATIVE_REQUIRE_PATTERN)) { + const specifier = match[2] + const resolved = resolveRelativeRequire(file, specifier) + if (resolved === null) { + missing.push({ from: path.relative(projectDir, file), specifier }) + continue + } + // Why: transitive edges matter — out/cli requires out/shared modules that + // may themselves require paths electron-vite's clean step removed. + if (resolved.endsWith('.js') && !visited.has(resolved)) { + visited.add(resolved) + queue.push(resolved) + } + } + } + return { checkedFiles: visited.size, missing, skipped: false } +} + +function main() { + const result = verifyCliRequireResolution() + if (result.skipped) { + console.log('[cli-requires] out/cli not present; skipping (run build:cli first)') + return + } + if (result.missing.length > 0) { + console.error( + `[cli-requires] ${result.missing.length} relative require(s) in the compiled CLI graph do not resolve in out/:` + ) + for (const edge of result.missing) { + console.error(` ${edge.from} -> require('${edge.specifier}')`) + } + console.error( + '[cli-requires] A CLI import of an out/main module needs a matching rollup input entry in electron.vite.config.ts, or the module belongs in src/shared.' + ) + process.exit(1) + } + console.log( + `[cli-requires] verified ${result.checkedFiles} compiled module(s), all requires resolve` + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/verify-cli-require-resolution.test.mjs b/config/scripts/verify-cli-require-resolution.test.mjs new file mode 100644 index 00000000000..b9b33456291 --- /dev/null +++ b/config/scripts/verify-cli-require-resolution.test.mjs @@ -0,0 +1,84 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { verifyCliRequireResolution } from './verify-cli-require-resolution.mjs' + +const tempDirs = [] + +function makeProject(files) { + const projectDir = mkdtempSync(path.join(tmpdir(), 'orca-cli-requires-')) + tempDirs.push(projectDir) + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(projectDir, relativePath) + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(filePath, content, 'utf8') + } + return projectDir +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { recursive: true, force: true }) + } +}) + +describe('verifyCliRequireResolution', () => { + it('passes when every relative require resolves in out/', () => { + const projectDir = makeProject({ + 'out/cli/index.js': `const a = require("./handlers/agent-hooks");`, + 'out/cli/handlers/agent-hooks.js': `require("../../shared/constants");`, + 'out/shared/constants.js': 'module.exports = {}' + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([]) + expect(result.checkedFiles).toBe(3) + }) + + it('reports a CLI require of a main module electron-vite removed', () => { + // The 1.4.150-rc.1.perf shape: tsc emitted the module, electron-vite's + // out/main clean deleted it, and the package shipped without it. + const projectDir = makeProject({ + 'out/cli/handlers/agent-hooks.js': `require("../../main/agent-launch/agent-catalog-schema-migration");` + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([ + { + from: path.join('out', 'cli', 'handlers', 'agent-hooks.js'), + specifier: '../../main/agent-launch/agent-catalog-schema-migration' + } + ]) + }) + + it('follows transitive requires out of out/cli into out/shared', () => { + const projectDir = makeProject({ + 'out/cli/index.js': `require("../shared/session-key");`, + 'out/shared/session-key.js': `require("../main/deleted-by-clean");` + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([ + { from: path.join('out', 'shared', 'session-key.js'), specifier: '../main/deleted-by-clean' } + ]) + }) + + it('resolves directory index and json targets and ignores package requires', () => { + const projectDir = makeProject({ + 'out/cli/index.js': ` + require("./handlers"); + require("./config.json"); + require("commander"); + `, + 'out/cli/handlers/index.js': 'module.exports = {}', + 'out/cli/config.json': '{}' + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([]) + }) + + it('skips when out/cli has not been built', () => { + const projectDir = makeProject({ 'out/main/index.js': '' }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.skipped).toBe(true) + expect(result.missing).toEqual([]) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44f58cae6c5..93d556602f8 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -18,7 +18,6 @@ "../src/main/agent-hooks/runtime-home-hook-command.ts", "../src/main/agent-hooks/windows-powershell-hook-launcher.ts", "../src/main/amp/agent-status-plugin-source.ts", - "../src/main/agent-launch/agent-catalog-schema-migration.ts", "../src/main/amp/hook-service.ts", "../src/main/amp/managed-plugin-install-status.ts", "../src/main/antigravity/hook-events.ts", diff --git a/package.json b/package.json index d1e1fa4d5fb..628a9c3a0bd 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", "verify:built-skills-cli": "node config/scripts/verify-skills-cli-runtime.cjs out", + "verify:cli-requires": "node config/scripts/verify-cli-require-resolution.mjs", "verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs", "sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix", "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", @@ -77,7 +78,7 @@ "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", - "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", + "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs && node config/scripts/verify-cli-require-resolution.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index e5250f972f0..32d12906cd6 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -20,7 +20,7 @@ import { getManagedAgentHookStatuses, prepareManagedCodexHomeBeforeShellLaunch } from '../../main/agent-hooks/managed-agent-hook-controls' -import { AGENT_CATALOG_SCHEMA_VERSION } from '../../main/agent-launch/agent-catalog-schema-migration' +import { AGENT_CATALOG_SCHEMA_VERSION } from '../../shared/agent-catalog-schema-migration' // Why: offline writes must be schema-transparent for the agent catalog — merging // defaults would stamp agentCatalogSchemaVersion on a pre-v1 file without the diff --git a/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts b/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts index 939af853bbc..eca7cf63116 100644 --- a/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts +++ b/src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts @@ -12,10 +12,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { CustomTuiAgentId, GlobalSettings } from '../../shared/types' import { AGENT_CATALOG_SCHEMA_VERSION, - createPinnedPreV1Backup, - migrateAgentCatalogSchema, - pinnedPreV1BackupPath -} from './agent-catalog-schema-migration' + migrateAgentCatalogSchema +} from '../../shared/agent-catalog-schema-migration' +import { createPinnedPreV1Backup, pinnedPreV1BackupPath } from './agent-catalog-pre-v1-backup' import { resolveAgentLaunch } from './resolve-agent-launch' import { catalogOf, diff --git a/src/main/agent-launch/agent-catalog-pre-v1-backup.test.ts b/src/main/agent-launch/agent-catalog-pre-v1-backup.test.ts new file mode 100644 index 00000000000..51bd2bf9822 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-pre-v1-backup.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createPinnedPreV1Backup, pinnedPreV1BackupPath } from './agent-catalog-pre-v1-backup' + +const tempDirs: string[] = [] + +function makeDataFile(contents: string, mode?: number): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) + tempDirs.push(dir) + const dataFile = join(dir, 'orca-data.json') + writeFileSync(dataFile, contents, mode !== undefined ? { mode } : undefined) + return dataFile +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('createPinnedPreV1Backup', () => { + it('writes the exact raw bytes with matching permissions', () => { + const raw = '{"settings":{"defaultTuiAgent":null}}' + const dataFile = makeDataFile(raw, 0o600) + const result = createPinnedPreV1Backup(dataFile, raw) + expect(result).toEqual({ ok: true, created: true }) + const backupFile = pinnedPreV1BackupPath(dataFile) + expect(readFileSync(backupFile, 'utf-8')).toBe(raw) + expect(statSync(backupFile).mode & 0o777).toBe(statSync(dataFile).mode & 0o777) + }) + + it('keeps an existing pinned backup instead of overwriting it', () => { + const original = '{"original":true}' + const dataFile = makeDataFile(original) + expect(createPinnedPreV1Backup(dataFile, original)).toEqual({ ok: true, created: true }) + const second = createPinnedPreV1Backup(dataFile, '{"newer":true}') + expect(second).toEqual({ ok: true, created: false }) + expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf-8')).toBe(original) + }) + + it('fails without leaving a partial backup when the data file is unreadable', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) + tempDirs.push(dir) + const missing = join(dir, 'missing.json') + const result = createPinnedPreV1Backup(missing, '{}') + expect(result.ok).toBe(false) + expect(existsSync(pinnedPreV1BackupPath(missing))).toBe(false) + expect(existsSync(`${pinnedPreV1BackupPath(missing)}.tmp`)).toBe(false) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-pre-v1-backup.ts b/src/main/agent-launch/agent-catalog-pre-v1-backup.ts new file mode 100644 index 00000000000..41bf6413fb7 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-pre-v1-backup.ts @@ -0,0 +1,54 @@ +// Pinned pre-v1 backup writer injected into the agent-catalog v1 schema +// migration (src/shared/agent-catalog-schema-migration.ts). Kept in main +// because it is fs-bound; the migration itself is pure and shared with the CLI. + +import { + closeSync, + existsSync, + fsyncSync, + openSync, + renameSync, + statSync, + unlinkSync, + writeSync +} from 'node:fs' +import type { PinnedBackupResult } from '../../shared/agent-catalog-schema-migration' + +export function pinnedPreV1BackupPath(dataFile: string): string { + return `${dataFile}.pre-agent-catalog-v1.backup` +} + +/** Write the exact pre-v1 raw bytes to the pinned backup with the data file's + * permissions, fsync, then atomically rename into place. An existing pinned + * backup is kept (a crash between backup and first v1 write must not let a + * second attempt overwrite the original pre-v1 state). */ +export function createPinnedPreV1Backup(dataFile: string, rawContents: string): PinnedBackupResult { + const backupFile = pinnedPreV1BackupPath(dataFile) + try { + if (existsSync(backupFile)) { + return { ok: true, created: false } + } + const mode = statSync(dataFile).mode & 0o777 + const tmpFile = `${backupFile}.tmp` + const fd = openSync(tmpFile, 'w', mode) + try { + writeSync(fd, rawContents) + fsyncSync(fd) + } finally { + closeSync(fd) + } + try { + renameSync(tmpFile, backupFile) + } catch (error) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort tmp cleanup; the rename failure is the reported error. + } + throw error + } + return { ok: true, created: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/data-recovery/recovery-points.ts b/src/main/data-recovery/recovery-points.ts index dc30bba6468..d6ac6185dac 100644 --- a/src/main/data-recovery/recovery-points.ts +++ b/src/main/data-recovery/recovery-points.ts @@ -13,7 +13,7 @@ import { unlinkSync, writeSync } from 'node:fs' -import { pinnedPreV1BackupPath } from '../agent-launch/agent-catalog-schema-migration' +import { pinnedPreV1BackupPath } from '../agent-launch/agent-catalog-pre-v1-backup' import type { RecoveryPointDto, RecoveryPointId } from '../../shared/data-recovery' export type { RecoveryPointDto, RecoveryPointId } from '../../shared/data-recovery' diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 6b86e94de9e..59fe53fafd6 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -41,10 +41,8 @@ import { hasStateBackup } from './backup-recovery-rotation' import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' -import { - createPinnedPreV1Backup, - migrateAgentCatalogSchema -} from '../../agent-launch/agent-catalog-schema-migration' +import { migrateAgentCatalogSchema } from '../../../shared/agent-catalog-schema-migration' +import { createPinnedPreV1Backup } from '../../agent-launch/agent-catalog-pre-v1-backup' type PersistenceStartupDetails = Record | (() => Record) diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index e2f9796cd66..5f46fa26115 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -18,10 +18,8 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' -import { - createPinnedPreV1Backup, - migrateAgentCatalogSchema -} from '../../agent-launch/agent-catalog-schema-migration' +import { migrateAgentCatalogSchema } from '../../../shared/agent-catalog-schema-migration' +import { createPinnedPreV1Backup } from '../../agent-launch/agent-catalog-pre-v1-backup' type ProfilePreferencesRuntime = Pick< StoreRuntimeState, diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts index 1e033adf052..60acfa107d1 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts @@ -1,9 +1,11 @@ -import type { WorktreeSlice } from '../../worktree-helpers' +import { getRepoIdFromWorktreeId, type WorktreeSlice } from '../../worktree-helpers' import type { WorktreeSliceGet, WorktreeSliceSet } from './worktree-slice-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import { getRepoExecutionHostId, - parseExecutionHostId + parseExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId } from '../../../../../../shared/execution-host' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' @@ -18,6 +20,25 @@ import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' import { mapReposForWorktreeRefresh } from './worktree-refresh-pool' import { settingsForKnownRepoOwner } from './worktree-owner-settings' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +let lastHydrationPurgeDeferralSignature: string | null = null + +function recordHydrationPurgeDeferralBreadcrumb(data: { + deferredUnknownOwner: number + deferredUncoveredHost: number + removed: number + repoCount: number + localRepoCount: number + coveredHosts: string +}): void { + const signature = JSON.stringify(data) + if (signature === lastHydrationPurgeDeferralSignature) { + return + } + lastHydrationPurgeDeferralSignature = signature + recordRendererCrashBreadcrumb('worktree_purge.hydration_deferred', data) +} export function createFetchAllWorktrees( set: WorktreeSliceSet, @@ -180,14 +201,70 @@ export function createFetchAllWorktrees( validIds.add(w.id) } } - const stale = Object.keys(get().tabsByWorktree).filter((id) => !validIds.has(id)) + const repoHostById = new Map( + get().repos.map((r) => [r.id, getRepoExecutionHostId(r)]) + ) + const coveredHostIds = new Set() + for (const repo of get().repos) { + const detected = get().detectedWorktreesByRepo[repo.id] + if (detected?.authoritative && detected.worktrees.length > 0) { + coveredHostIds.add(getRepoExecutionHostId(repo)) + } + } + const deferredUnknownOwner: string[] = [] + const deferredUncoveredHost: string[] = [] + const stale: string[] = [] + let staleLocalOwners = 0 + for (const id of Object.keys(get().tabsByWorktree)) { + if (validIds.has(id)) { + continue + } + const ownerHostId = repoHostById.get(getRepoIdFromWorktreeId(id)) + if (ownerHostId === undefined) { + deferredUnknownOwner.push(id) + } else if (!coveredHostIds.has(ownerHostId)) { + deferredUncoveredHost.push(id) + } else { + if (ownerHostId === LOCAL_EXECUTION_HOST_ID) { + staleLocalOwners += 1 + } + stale.push(id) + } + } + const localRepoCount = [...repoHostById.values()].filter( + (hostId) => hostId === LOCAL_EXECUTION_HOST_ID + ).length if (stale.length > 0) { console.warn( `[worktree-purge] hydration-time purge removing stale state for ${stale.length} worktree(s):`, stale ) + recordRendererCrashBreadcrumb('worktree_purge.hydration', { + removed: stale.length, + removedLocalOwners: staleLocalOwners, + removedRemoteOwners: stale.length - staleLocalOwners, + deferredUnknownOwner: deferredUnknownOwner.length, + deferredUncoveredHost: deferredUncoveredHost.length, + repoCount: repoHostById.size, + localRepoCount, + coveredHosts: [...coveredHostIds].join(',') + }) get().purgeWorktreeTerminalState(stale) } + if (deferredUnknownOwner.length > 0 || deferredUncoveredHost.length > 0) { + console.warn( + `[worktree-purge] deferring hydration purge for ${deferredUnknownOwner.length} unknown-owner and ${deferredUncoveredHost.length} uncovered-host worktree(s); repo hydration looks incomplete` + ) + recordHydrationPurgeDeferralBreadcrumb({ + deferredUnknownOwner: deferredUnknownOwner.length, + deferredUncoveredHost: deferredUncoveredHost.length, + removed: stale.length, + repoCount: repoHostById.size, + localRepoCount, + coveredHosts: [...coveredHostIds].join(',') + }) + return + } set({ hasHydratedWorktreePurge: true }) } } diff --git a/src/main/agent-launch/agent-catalog-schema-migration.test.ts b/src/shared/agent-catalog-schema-migration.test.ts similarity index 69% rename from src/main/agent-launch/agent-catalog-schema-migration.test.ts rename to src/shared/agent-catalog-schema-migration.test.ts index 9a17ab045d0..4f0089c50fc 100644 --- a/src/main/agent-launch/agent-catalog-schema-migration.test.ts +++ b/src/shared/agent-catalog-schema-migration.test.ts @@ -1,62 +1,5 @@ -import { describe, expect, it, afterEach } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { - createPinnedPreV1Backup, - migrateAgentCatalogSchema, - pinnedPreV1BackupPath -} from './agent-catalog-schema-migration' - -const tempDirs: string[] = [] - -function makeDataFile(contents: string, mode?: number): string { - const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) - tempDirs.push(dir) - const dataFile = join(dir, 'orca-data.json') - writeFileSync(dataFile, contents, mode !== undefined ? { mode } : undefined) - return dataFile -} - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop() - if (dir) { - rmSync(dir, { recursive: true, force: true }) - } - } -}) - -describe('createPinnedPreV1Backup', () => { - it('writes the exact raw bytes with matching permissions', () => { - const raw = '{"settings":{"defaultTuiAgent":null}}' - const dataFile = makeDataFile(raw, 0o600) - const result = createPinnedPreV1Backup(dataFile, raw) - expect(result).toEqual({ ok: true, created: true }) - const backupFile = pinnedPreV1BackupPath(dataFile) - expect(readFileSync(backupFile, 'utf-8')).toBe(raw) - expect(statSync(backupFile).mode & 0o777).toBe(statSync(dataFile).mode & 0o777) - }) - - it('keeps an existing pinned backup instead of overwriting it', () => { - const original = '{"original":true}' - const dataFile = makeDataFile(original) - expect(createPinnedPreV1Backup(dataFile, original)).toEqual({ ok: true, created: true }) - const second = createPinnedPreV1Backup(dataFile, '{"newer":true}') - expect(second).toEqual({ ok: true, created: false }) - expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf-8')).toBe(original) - }) - - it('fails without leaving a partial backup when the data file is unreadable', () => { - const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-')) - tempDirs.push(dir) - const missing = join(dir, 'missing.json') - const result = createPinnedPreV1Backup(missing, '{}') - expect(result.ok).toBe(false) - expect(existsSync(pinnedPreV1BackupPath(missing))).toBe(false) - expect(existsSync(`${pinnedPreV1BackupPath(missing)}.tmp`)).toBe(false) - }) -}) +import { describe, expect, it } from 'vitest' +import { migrateAgentCatalogSchema } from './agent-catalog-schema-migration' describe('migrateAgentCatalogSchema', () => { it('maps shipped legacy null (and missing) defaults to auto exactly once', () => { diff --git a/src/main/agent-launch/agent-catalog-schema-migration.ts b/src/shared/agent-catalog-schema-migration.ts similarity index 75% rename from src/main/agent-launch/agent-catalog-schema-migration.ts rename to src/shared/agent-catalog-schema-migration.ts index 533a810550f..8b285053500 100644 --- a/src/main/agent-launch/agent-catalog-schema-migration.ts +++ b/src/shared/agent-catalog-schema-migration.ts @@ -4,62 +4,18 @@ // existing profile, a pinned same-permission pre-v1 backup is created beside // the rotating backups; if that backup cannot be created, no v1 write happens // and launch behavior stays on the clean built-in baseline. +// +// Why shared: the CLI's offline settings writes need AGENT_CATALOG_SCHEMA_VERSION +// for their schema-newer-than-this-CLI guard, and out/main does not survive +// electron-vite rebuilds. The fs-bound backup writer this migration injects +// lives in src/main/agent-launch/agent-catalog-pre-v1-backup.ts. -import { - closeSync, - existsSync, - fsyncSync, - openSync, - renameSync, - statSync, - unlinkSync, - writeSync -} from 'node:fs' -import type { GlobalSettings } from '../../shared/types' +import type { GlobalSettings } from './types' export const AGENT_CATALOG_SCHEMA_VERSION = 1 -export function pinnedPreV1BackupPath(dataFile: string): string { - return `${dataFile}.pre-agent-catalog-v1.backup` -} - export type PinnedBackupResult = { ok: true; created: boolean } | { ok: false; error: string } -/** Write the exact pre-v1 raw bytes to the pinned backup with the data file's - * permissions, fsync, then atomically rename into place. An existing pinned - * backup is kept (a crash between backup and first v1 write must not let a - * second attempt overwrite the original pre-v1 state). */ -export function createPinnedPreV1Backup(dataFile: string, rawContents: string): PinnedBackupResult { - const backupFile = pinnedPreV1BackupPath(dataFile) - try { - if (existsSync(backupFile)) { - return { ok: true, created: false } - } - const mode = statSync(dataFile).mode & 0o777 - const tmpFile = `${backupFile}.tmp` - const fd = openSync(tmpFile, 'w', mode) - try { - writeSync(fd, rawContents) - fsyncSync(fd) - } finally { - closeSync(fd) - } - try { - renameSync(tmpFile, backupFile) - } catch (error) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort tmp cleanup; the rename failure is the reported error. - } - throw error - } - return { ok: true, created: true } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } -} - export type AgentCatalogSchemaMigrationOutcome = { /** Patch merged into loaded settings; empty object when nothing changed. */ settingsPatch: Partial diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 0913a8a6ed7..fa8bb21af8b 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -1,7 +1,7 @@ import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../../src/shared/constants' import { FEATURE_INTERACTION_IDS } from '../../../src/shared/feature-interactions' import { FEATURE_TIP_IDS } from '../../../src/shared/feature-tips' -import { AGENT_CATALOG_SCHEMA_VERSION } from '../../../src/main/agent-launch/agent-catalog-schema-migration' +import { AGENT_CATALOG_SCHEMA_VERSION } from '../../../src/shared/agent-catalog-schema-migration' import type { CustomTuiAgent } from '../../../src/shared/types' import type { SourceControlActionRecipe } from '../../../src/shared/source-control-ai-actions'