mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
+2
-59
@@ -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', () => {
|
||||
+6
-50
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user