diff --git a/scripts/validation/extensions-recovery.cjs b/scripts/validation/extensions-recovery.cjs index 0f647c93..72d5d677 100644 --- a/scripts/validation/extensions-recovery.cjs +++ b/scripts/validation/extensions-recovery.cjs @@ -143,7 +143,7 @@ if (process.argv[2] === '--child') { await run(repeated); assert.deepEqual(inspect(repeated), before); clean(repeated); receipt({ action: 'kill recovery itself', boundary: 'recovery_config', decision: 'idempotent rollback', passed: true }); const live = await project(true), c = child(live, 'update', 'config_written'); - await c.reached; + await Promise.race([c.reached, c.done.then(r => { throw new Error('Live-owner fixture exited early: ' + r.stderr); })]); const lock = path.join(live, '.codegraph/plugins/operation.lock'); fs.utimesSync(lock, 1, 1); for (const action of ['install', 'inspect', 'index']) { const result = await run(live, action, 1); assert.match(result.stderr, /operation is active/); @@ -183,6 +183,29 @@ if (process.argv[2] === '--child') { await run(conflict); clean(conflict); assert.equal(JSON.parse(config(conflict)).resolvedByUser, true); receipt({ action: 'conflicting plugin edit', boundary, decision: 'no overwrite; actionable repair then recovery', passed: true }); } + const settings = await project(true); await killAt(settings, 'update', 'config_written'); + const changedSettings = JSON.parse(config(settings)); changedSettings.exclude = ['handlers.py']; + const changedRaw = JSON.stringify(changedSettings); fs.writeFileSync(path.join(settings, 'codegraph.json'), changedRaw); + assert.match((await run(settings, 'inspect', 1)).stderr, /exclude was edited externally/); + assert.equal(config(settings), changedRaw); + delete changedSettings.exclude; fs.writeFileSync(path.join(settings, 'codegraph.json'), JSON.stringify(changedSettings)); + await run(settings); clean(settings); + receipt({ action: 'graph-affecting external config edit', decision: 'preserved and blocked with repair instructions', passed: true }); + const tamper = await project(true); await killAt(tamper, 'update', 'graph_committed'); + const entry = JSON.parse(config(tamper)).plugins[0]; + const entryFile = path.join(tamper, '.codegraph/plugins/packages', entry.integrity, 'index.cjs'), originalEntry = fs.readFileSync(entryFile); + fs.appendFileSync(entryFile, '\n// external change'); + assert.match((await run(tamper, 'inspect', 1)).stderr, /installed package managed:recovery-demo is missing or changed/); + assert.ok(fs.existsSync(path.join(tamper, '.codegraph/plugins/transaction.json'))); + fs.writeFileSync(entryFile, originalEntry); await run(tamper); clean(tamper); + receipt({ action: 'changed installed package', decision: 'trust revalidated; blocked until bytes restored', passed: true }); + const cached = await project(true), reader = await CodeGraph.open(cached); + try { + const node = reader.getNodesByKind('route')[0]; assert.equal(reader.getNode(node.id).name, '/1.0.0'); + await killAt(cached, 'update', 'graph_committed'); await run(cached); clean(cached); + assert.equal(reader.getNode(node.id).name, '/2.0.0'); + } finally { reader.close(); } + receipt({ action: 'long-lived reader after fresh-process recovery', decision: 'old cached node invalidated by commit marker', passed: true }); fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, node: process.version, rows, commands: commands.length }, null, 2)); } finally { for (const c of processes) c.kill('SIGKILL'); diff --git a/src/index.ts b/src/index.ts index 93d0bb51..4a0c4808 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import * as path from 'path'; import * as fs from 'node:fs'; import { randomUUID } from 'node:crypto'; -import { recoverExtensions, withExtensionGuard, extensionTransition } from './plugins/recovery'; +import { recoverExtensions, withExtensionGuard, withExtensionGuardSync, extensionTransition } from './plugins/recovery'; import { loadPlugins, pluginFingerprint } from './plugins/loader'; import { withPlugins, currentPlugins, type PluginRegistry } from './plugins/registry'; import { loadPluginEntries } from './project-config'; @@ -136,6 +136,8 @@ export interface OpenOptions { export interface IndexOptions { /** @internal Managed lifecycle transaction marker, committed with graph data. */ extensionTransactionId?: string; + /** @internal Revalidate the staged config/trust before the atomic graph commit. */ + beforeExtensionCommit?: () => void; /** Progress callback */ onProgress?: (progress: IndexProgress) => void; @@ -157,6 +159,7 @@ export class CodeGraph { private db: DatabaseConnection; private queries: QueryBuilder; private projectRoot: string; + private extensionCommit: string | null = null; // Assigned via wireLayers() from the constructor (and again on reopen) — the // `!` tells TS these are definitely set even though the assignment is one // method call away from the constructor body. @@ -248,8 +251,11 @@ export class CodeGraph { } private ensureExtensionState(): void { - if (recoverExtensions(this.projectRoot)) { + const recovered = recoverExtensions(this.projectRoot); + const commit = this.queries.getMetadata('extension_transaction'); + if (recovered || commit !== this.extensionCommit) { this.queries.clearCache(); this.resolver.clearCaches(); + this.extensionCommit = commit; } } @@ -299,6 +305,11 @@ export class CodeGraph { * @returns A new CodeGraph instance */ static async init(projectRoot: string, options: InitOptions = {}): Promise { + fs.mkdirSync(path.resolve(projectRoot), { recursive: true }); + return withExtensionGuard(projectRoot, () => CodeGraph.initGuarded(projectRoot, options)); + } + + private static async initGuarded(projectRoot: string, options: InitOptions): Promise { await initGrammars(); const resolvedRoot = path.resolve(projectRoot); recoverExtensions(resolvedRoot); @@ -330,6 +341,11 @@ export class CodeGraph { * Initialize synchronously (without indexing) */ static initSync(projectRoot: string): CodeGraph { + fs.mkdirSync(path.resolve(projectRoot), { recursive: true }); + return withExtensionGuardSync(projectRoot, () => CodeGraph.initSyncGuarded(projectRoot)); + } + + private static initSyncGuarded(projectRoot: string): CodeGraph { const resolvedRoot = path.resolve(projectRoot); recoverExtensions(resolvedRoot); @@ -545,6 +561,7 @@ export class CodeGraph { const connection = this.db.getDb(); connection.prepare('ATTACH DATABASE ? AS extension_candidate').run(stagingPath); attached = true; + options.beforeExtensionCommit?.(); connection.transaction(() => { this.queries.clear(); for (const table of ['files', 'nodes', 'edges', 'unresolved_refs']) { @@ -2406,6 +2423,7 @@ export class CodeGraph { input: TaskInput, options?: BuildContextOptions ): Promise { + this.ensureExtensionState(); return this.contextBuilder.buildContext(input, options); } @@ -2417,14 +2435,14 @@ export class CodeGraph { * Optimize the database (vacuum and analyze) */ optimize(): void { - this.db.optimize(); + withExtensionGuardSync(this.projectRoot, () => this.db.optimize()); } /** * Clear all data from the graph */ clear(): void { - this.queries.clear(); + withExtensionGuardSync(this.projectRoot, () => this.queries.clear()); } /** diff --git a/src/plugins/manager.ts b/src/plugins/manager.ts index 73d5ec6d..07a43b1b 100644 --- a/src/plugins/manager.ts +++ b/src/plugins/manager.ts @@ -120,7 +120,7 @@ export class ExtensionManager { this.onProgress({ state: 'indexing', message: 'Building and checking the updated graph' }); const { CodeGraph } = await import('../index'); const graph = CodeGraph.isInitialized(this.root) ? await CodeGraph.open(this.root) : await CodeGraph.init(this.root); - try { await graph.refreshPluginIndex({ extensionTransactionId: transaction.record.id }); } finally { graph.close(); } + try { await graph.refreshPluginIndex({ extensionTransactionId: transaction.record.id, beforeExtensionCommit: () => transaction.assertStaged() }); } finally { graph.close(); } transaction.reconcile(); } catch (error) { // The durable graph marker decides rollback vs completion, including diff --git a/src/plugins/recovery.ts b/src/plugins/recovery.ts index 28050f74..cba8cf40 100644 --- a/src/plugins/recovery.ts +++ b/src/plugins/recovery.ts @@ -13,7 +13,9 @@ const transitions = channel('codegraph.extension.transaction'); const dir = (root: string) => path.join(root, '.codegraph', 'plugins'); const recordPath = (root: string) => path.join(dir(root), 'transaction.json'); const ownerPath = (root: string) => path.join(dir(root), 'operation.lock'); -const uuid = /^[a-f0-9-]{36}$/; +const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const indexSettings = ['extensions', 'includeIgnored', 'exclude', 'include']; +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); interface RecordV1 { format: 1; id: string; pid: number; graphExisted: boolean; previousConfig: string | null; nextConfig: string; previousTrust: string | null; nextTrust: string | null; @@ -88,7 +90,7 @@ function checkOwner(root: string, journal?: RecordV1): void { const owner = JSON.parse(raw); // For current writers, the acquired SQLite lock is authoritative even if // this PID is alive/reused. It cannot be acquired during a live operation. - if (owner?.format === 'codegraph-operation-lock-1' && uuid.test(owner.id) && Number.isSafeInteger(owner.pid)) return; + if (owner?.format === 'codegraph-operation-lock-1' && uuid.test(owner.id) && Number.isSafeInteger(owner.pid) && owner.pid > 0) return; } catch { /* legacy/torn */ } if (journal) return; // durable v1 journal + acquired SQLite lock prove orphan if (/^[1-9]\d*$/.test(raw.trim())) { @@ -132,6 +134,21 @@ function cleanup(root: string, r: RecordV1, committed: boolean): void { const graphLock = path.join(root, '.codegraph', 'codegraph.lock'); if (read(graphLock)?.trim() === String(r.pid)) remove(graphLock); } +/** Validate the version/trust side before removing the only recovery record. */ +function verifyPackages(root: string, config: string | null, trust: string | null): void { + const entries = object(config).plugins, trusted = object(trust); + if (!Array.isArray(entries)) return; + for (const entry of entries) { + if (!entry || typeof entry.name !== 'string' || !entry.name.startsWith('managed:')) continue; + if (!/^[a-f0-9]{64}$/.test(entry.integrity)) fail(root, 'invalid installed package integrity'); + const packageRoot = path.join(dir(root), 'packages', entry.integrity); + try { + const pkg = object(read(path.join(packageRoot, 'package.json'))); + if (pkg.version !== entry.version || (pkg.codegraph as { id?: string })?.id !== entry.name.slice(8) || + packageDigest(packageRoot) !== trusted[fs.realpathSync(packageRoot)]) throw new Error('version/identity/trust mismatch'); + } catch (error) { fail(root, `installed package ${entry.name} is missing or changed (${String(error)}); restore the recorded package bytes/trust from backup before recovery`); } + } +} /** Reconcile synchronously under the coordinator; never evaluates extension code. */ function reconcile(root: string): boolean { const r = loadRecord(root); checkOwner(root, r); @@ -142,7 +159,8 @@ function reconcile(root: string): boolean { let targetConfig: string | null, targetTrust: string | null; try { const previous = object(r.previousConfig), next = object(r.nextConfig), current = object(configRaw); - const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + for (const key of indexSettings) if (!same(current[key], next[key])) + fail(root, `${key} was edited externally and may change the graph; save that edit separately, restore the recorded ${key}, recover, then reapply it and run codegraph index`); const wanted = committed ? next.plugins : previous.plugins; if (!same(current.plugins, wanted) && (committed || !same(current.plugins, next.plugins))) fail(root, `plugins were edited externally; graph ${committed ? 'committed the new version (restore nextConfig.plugins)' : 'retains the old version (restore previousConfig.plugins)'}`); @@ -160,6 +178,7 @@ function reconcile(root: string): boolean { } targetTrust = trustRaw === r.nextTrust || trustRaw === r.previousTrust ? (committed ? r.nextTrust : r.previousTrust) : JSON.stringify(currentTrust); } catch (error) { return fail(root, String(error)); } + verifyPackages(root, targetConfig, targetTrust); replaceObserved(root, configFile, configRaw, targetConfig); extensionTransition(root, r.id, 'recovery_config'); replaceObserved(root, trustFile, trustRaw, targetTrust); @@ -179,6 +198,13 @@ export function recoverExtensions(root: string): boolean { const release = acquire(root); try { return context.run(root, () => reconcile(root)); } finally { release(); } } +export function withExtensionGuardSync(root: string, work: () => T): T { + root = fs.realpathSync(root); + if (context.getStore() === root) return work(); + const release = acquire(root); + try { return context.run(root, () => { reconcile(root); return work(); }); } + finally { release(); } +} /** Indexers and lifecycle writers share this OS-released lock for their full run. */ export async function withExtensionGuard(root: string, work: () => Promise): Promise { root = fs.realpathSync(root); @@ -222,5 +248,11 @@ export class ExtensionTransaction { replaceObserved(this.root, path.join(this.root, 'codegraph.json'), r.previousConfig, r.nextConfig); clearProjectConfigCache(); extensionTransition(this.root, r.id, 'config_written'); } + assertStaged(): void { + const expected = object(this.record.nextConfig), current = object(read(path.join(this.root, 'codegraph.json'))); + for (const key of ['plugins', ...indexSettings]) if (!same(current[key], expected[key])) + fail(this.root, `${key} changed while building the candidate; preserve the edit and reconcile it with the recorded config`); + verifyPackages(this.root, this.record.nextConfig, read(path.join(dir(this.root), 'trust.json'))); + } reconcile(): void { reconcile(this.root); } }