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).
This commit is contained in:
Jinjing
2026-09-01 03:53:17 -07:00
parent 4576ddf77f
commit e9063f106d
15 changed files with 401 additions and 129 deletions
@@ -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)
}
}
@@ -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([])
})
})
-1
View File
@@ -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",
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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
@@ -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,
@@ -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)
})
})
@@ -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) }
}
}
+1 -1
View File
@@ -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'
@@ -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<string, unknown> | (() => Record<string, unknown>)
@@ -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,
@@ -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<string, ExecutionHostId>(
get().repos.map((r) => [r.id, getRepoExecutionHostId(r)])
)
const coveredHostIds = new Set<ExecutionHostId>()
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 })
}
}
@@ -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', () => {
@@ -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<GlobalSettings>
@@ -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'