mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
Validate recovered trust and config; refresh readers after graph commits
This commit is contained in:
@@ -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');
|
||||
|
||||
+22
-4
@@ -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<CodeGraph> {
|
||||
fs.mkdirSync(path.resolve(projectRoot), { recursive: true });
|
||||
return withExtensionGuard(projectRoot, () => CodeGraph.initGuarded(projectRoot, options));
|
||||
}
|
||||
|
||||
private static async initGuarded(projectRoot: string, options: InitOptions): Promise<CodeGraph> {
|
||||
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<TaskContext | string> {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-3
@@ -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<T>(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<T>(root: string, work: () => Promise<T>): Promise<T> {
|
||||
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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user