mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
fix(extensions): honor configured graph directory during recovery
This commit is contained in:
@@ -154,6 +154,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Extension installation and interrupted-operation recovery now honor `CODEGRAPH_DIR` without creating or modifying the default data directory.
|
||||
|
||||
- Managed extensions remain trusted when a project is opened with different path capitalization on Windows and macOS.
|
||||
- Publisher forms preserve entered descriptions and source links while package metadata finishes loading.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { channel } = require('node:diagnostics_channel');
|
||||
const { randomUUID, createHash } = require('node:crypto');
|
||||
const { CodeGraph } = require('../../dist');
|
||||
const { CodeGraph, getCodeGraphDir, codeGraphDirName } = require('../../dist');
|
||||
const { ExtensionManager } = require('../../dist/plugins/manager');
|
||||
const { recoverExtensions } = require('../../dist/plugins/recovery');
|
||||
const { packageDigest } = require('../../dist/plugins/package');
|
||||
@@ -16,9 +16,9 @@ function artifact(version = '1.0.0') {
|
||||
files: { 'index.cjs': `module.exports=()=>({frameworks:[{name:'routes',languages:['python'],detect:()=>true,resolve:()=>null,extract(file){return {nodes:[{id:'plugin:recovery-demo:'+file,kind:'route',name:'/${version}',qualifiedName:file+'::route',filePath:file,language:'python',startLine:1,endLine:1,startColumn:0,endColumn:1,updatedAt:0}],references:[]}}}]});` } }));
|
||||
}
|
||||
function config(root) { return fs.existsSync(path.join(root, 'codegraph.json')) ? fs.readFileSync(path.join(root, 'codegraph.json'), 'utf8') : null; }
|
||||
function trust(root) { const file = path.join(root, '.codegraph/plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; }
|
||||
function trust(root) { const file = path.join(getCodeGraphDir(root), 'plugins/trust.json'); return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; }
|
||||
function inspect(root) {
|
||||
const dbPath = path.join(root, '.codegraph/codegraph.db');
|
||||
const dbPath = path.join(getCodeGraphDir(root), 'codegraph.db');
|
||||
let routes = [];
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
@@ -96,10 +96,11 @@ if (process.argv[2] === '--child') {
|
||||
return root;
|
||||
}
|
||||
function clean(root) {
|
||||
const folder = path.join(root, '.codegraph/plugins');
|
||||
if (codeGraphDirName() !== '.codegraph') assert.equal(fs.existsSync(path.join(root, '.codegraph')), false, 'override must not create default data directory');
|
||||
const folder = path.join(getCodeGraphDir(root), 'plugins');
|
||||
assert.ok(!fs.existsSync(path.join(folder, 'transaction.json')));
|
||||
assert.ok(!fs.existsSync(path.join(folder, 'operation.lock')));
|
||||
assert.ok(!fs.readdirSync(path.join(root, '.codegraph')).some(n => n.startsWith('extension-stage-')));
|
||||
assert.ok(!fs.readdirSync(getCodeGraphDir(root)).some(n => n.startsWith('extension-stage-')));
|
||||
assert.ok(!fs.readdirSync(folder).some(n => n.startsWith('stage-')));
|
||||
assert.equal(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8'), 'unrelated bytes');
|
||||
const entries = JSON.parse(config(root) || '{}').plugins || [], trusted = JSON.parse(trust(root) || '{}');
|
||||
@@ -133,7 +134,7 @@ if (process.argv[2] === '--child') {
|
||||
for (const boundary of ['package_ready', 'trust_written']) {
|
||||
const root = await project(true), before = inspect(root);
|
||||
await killAt(root, 'update', boundary); await run(root); assert.deepEqual(inspect(root), before); clean(root);
|
||||
const packages = fs.readdirSync(path.join(root, '.codegraph/plugins/packages')); assert.equal(packages.length, 1);
|
||||
const packages = fs.readdirSync(path.join(getCodeGraphDir(root), 'plugins/packages')); assert.equal(packages.length, 1);
|
||||
receipt({ action: 'update', boundary, decision: 'rollback and discard new package/trust', passed: true });
|
||||
}
|
||||
const first = await project(false, false);
|
||||
@@ -146,13 +147,13 @@ if (process.argv[2] === '--child') {
|
||||
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 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);
|
||||
const lock = path.join(getCodeGraphDir(live), '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/);
|
||||
}
|
||||
assert.ok(processes.has(c.processChild)); c.processChild.kill('SIGKILL'); await c.done; await run(live); clean(live);
|
||||
receipt({ action: 'live aged owner', decision: 'mutation/read/index excluded; no timeout stealing', passed: true });
|
||||
const reused = await project(true), owner = path.join(reused, '.codegraph/plugins/operation.lock');
|
||||
const reused = await project(true), owner = path.join(getCodeGraphDir(reused), 'plugins/operation.lock');
|
||||
fs.writeFileSync(owner, JSON.stringify({ format: 'codegraph-operation-lock-1', id: randomUUID(), pid: process.pid, startedAt: 1 }));
|
||||
await run(reused); clean(reused);
|
||||
fs.writeFileSync(owner, String(process.pid));
|
||||
@@ -161,31 +162,31 @@ if (process.argv[2] === '--child') {
|
||||
fs.writeFileSync(owner, String(deadPid)); await run(reused); clean(reused);
|
||||
receipt({ action: 'PID reuse and legacy owners', decision: 'SQLite ownership authoritative; live legacy blocked, dead legacy reclaimed', passed: true });
|
||||
const torn = await project(true); await killAt(torn, 'update', 'config_written');
|
||||
const journal = path.join(torn, '.codegraph/plugins/transaction.json'), original = fs.readFileSync(journal);
|
||||
fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{');
|
||||
const journal = path.join(getCodeGraphDir(torn), 'plugins/transaction.json'), original = fs.readFileSync(journal);
|
||||
fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{');
|
||||
fs.writeFileSync(journal, '{'); const state = inspect(torn);
|
||||
assert.match((await run(torn, 'inspect', 1)).stderr, /invalid transaction record/); assert.deepEqual(inspect(torn), state);
|
||||
fs.writeFileSync(journal, original); await run(torn); clean(torn);
|
||||
fs.writeFileSync(path.join(torn, '.codegraph/plugins/operation.lock'), '{');
|
||||
assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(torn, '.codegraph/plugins/operation.lock'));
|
||||
fs.writeFileSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'), '{');
|
||||
assert.match((await run(torn, 'inspect', 1)).stderr, /torn\/unknown owner/); fs.unlinkSync(path.join(getCodeGraphDir(torn), 'plugins/operation.lock'));
|
||||
receipt({ action: 'torn records', decision: 'invalid journal/unknown owner fail closed; valid journal recovers torn owner', passed: true });
|
||||
for (const boundary of ['config_written', 'graph_committed']) {
|
||||
const edited = await project(true); await killAt(edited, 'update', boundary);
|
||||
const cfg = JSON.parse(config(edited)); cfg.userNote = 'preserve this edit'; fs.writeFileSync(path.join(edited, 'codegraph.json'), JSON.stringify(cfg));
|
||||
const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(edited, '.codegraph/plugins/trust.json'), JSON.stringify(t));
|
||||
const t = JSON.parse(trust(edited)); t['unrelated-user-key'] = 'preserve'; fs.writeFileSync(path.join(getCodeGraphDir(edited), 'plugins/trust.json'), JSON.stringify(t));
|
||||
await run(edited); assert.equal(JSON.parse(config(edited)).userNote, 'preserve this edit'); assert.equal(JSON.parse(trust(edited))['unrelated-user-key'], 'preserve'); clean(edited);
|
||||
receipt({ action: 'unrelated config/trust edits', boundary, decision: 'preserved', passed: true });
|
||||
const conflict = await project(true); await killAt(conflict, 'update', boundary);
|
||||
const cfg2 = JSON.parse(config(conflict)); cfg2.plugins[0].options = { userEdit: true }; const editedRaw = JSON.stringify(cfg2);
|
||||
fs.writeFileSync(path.join(conflict, 'codegraph.json'), editedRaw);
|
||||
assert.match((await run(conflict, 'inspect', 1)).stderr, /plugins were edited externally/); assert.equal(config(conflict), editedRaw);
|
||||
const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(conflict, '.codegraph/plugins/transaction.json'))).payload);
|
||||
const r = JSON.parse(JSON.parse(fs.readFileSync(path.join(getCodeGraphDir(conflict), 'plugins/transaction.json'))).payload);
|
||||
cfg2.plugins = JSON.parse(boundary === 'graph_committed' ? r.nextConfig : r.previousConfig).plugins; cfg2.resolvedByUser = true;
|
||||
fs.writeFileSync(path.join(conflict, 'codegraph.json'), JSON.stringify(cfg2));
|
||||
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 graphLocked = await project(true), graphLock = path.join(graphLocked, '.codegraph/codegraph.lock');
|
||||
const graphLocked = await project(true), graphLock = path.join(getCodeGraphDir(graphLocked), 'codegraph.lock');
|
||||
fs.writeFileSync(graphLock, String(process.pid)); fs.utimesSync(graphLock, 1, 1);
|
||||
assert.match((await run(graphLocked, 'update', 1)).stderr, /legacy graph owner/);
|
||||
assert.equal(fs.readFileSync(graphLock, 'utf8'), String(process.pid)); fs.unlinkSync(graphLock);
|
||||
@@ -201,10 +202,10 @@ if (process.argv[2] === '--child') {
|
||||
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);
|
||||
const entryFile = path.join(getCodeGraphDir(tamper), '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')));
|
||||
assert.ok(fs.existsSync(path.join(getCodeGraphDir(tamper), '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);
|
||||
@@ -214,7 +215,7 @@ if (process.argv[2] === '--child') {
|
||||
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, arch: process.arch, node: process.version, termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2));
|
||||
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ platform: process.platform, arch: process.arch, node: process.version, dataDirectory: codeGraphDirName(), termination: process.platform === 'win32' ? 'Node child.kill(SIGKILL) / TerminateProcess' : 'SIGKILL', rows, commands: commands.length }, null, 2));
|
||||
} finally {
|
||||
for (const c of processes) c.kill('SIGKILL');
|
||||
if (processes.size) await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
@@ -19,13 +19,13 @@ const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_WASM_RELAUNCHE
|
||||
const playwright = process.env.PLAYWRIGHT_MODULE || path.join(root, '.qa/browser/node_modules/playwright');
|
||||
env.PLAYWRIGHT_MODULE = playwright;
|
||||
env.CHROME_PATH = process.env.CHROME_PATH || require(playwright).chromium.executablePath();
|
||||
async function run(name, command, args, timeout = 360000) {
|
||||
const step = { name, command: [command, ...args], cwd: root, revision: version, started: new Date().toISOString(), status: 'running' };
|
||||
async function run(name, command, args, timeout = 360000, overrides = {}) {
|
||||
const step = { name, command: [command, ...args], cwd: root, revision: version, overrides, started: new Date().toISOString(), status: 'running' };
|
||||
metadata.steps.push(step); save(); console.log('START', name);
|
||||
const logFile = path.join(out, name + '.log'); const log = fs.openSync(logFile, 'w');
|
||||
const started = Date.now();
|
||||
const result = await new Promise(resolve => {
|
||||
const child = spawn(command, args, { cwd: root, env, stdio: ['ignore', log, log], timeout });
|
||||
const child = spawn(command, args, { cwd: root, env: { ...env, ...overrides }, stdio: ['ignore', log, log], timeout });
|
||||
let error;
|
||||
child.on('error', e => error = String(e));
|
||||
child.on('close', (exit, signal) => resolve({ exit, signal, error }));
|
||||
@@ -42,12 +42,13 @@ async function run(name, command, args, timeout = 360000) {
|
||||
if (built) {
|
||||
await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace']));
|
||||
await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run',
|
||||
'__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
|
||||
'__tests__/foundation.test.ts', '__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
|
||||
'__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',
|
||||
'--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]);
|
||||
await run('compiled-workers', process.execPath, ['--test', 'scripts/validation/extensions-runtime.test.cjs']);
|
||||
await run('native-paths', process.execPath, ['scripts/validation/native-paths.cjs']);
|
||||
await run('process-recovery', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000);
|
||||
await run('process-recovery-override', process.execPath, ['scripts/validation/extensions-recovery.cjs'], 600000, { CODEGRAPH_DIR: '.codegraph-native', RECOVERY_OUTPUT: path.join(out, 'recovery-override') });
|
||||
await run('external-author', process.execPath, ['scripts/validation/extension-author.cjs'], 600000);
|
||||
await run('compatibility', process.execPath, ['scripts/validation/extensions-compatibility.cjs']);
|
||||
await run('browser', process.execPath, ['scripts/validation/marketplace-browser.cjs']);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getCodeGraphDir } from '../directory';
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { NODE_KINDS, EDGE_KINDS, LANGUAGES, type Node, type Edge } from '../types';
|
||||
@@ -11,7 +12,7 @@ import { loadPluginEntries } from '../project-config';
|
||||
import { version as engineVersion } from '../../package.json';
|
||||
|
||||
const importESM = new Function('url', 'return import(url)') as (url: string) => Promise<unknown>;
|
||||
export const pluginDirectory = (root: string): string => path.join(root, '.codegraph', 'plugins');
|
||||
export const pluginDirectory = (root: string): string => path.join(getCodeGraphDir(root), 'plugins');
|
||||
|
||||
function resolveEntry(root: string, entry: PluginEntry): ResolvedPlugin {
|
||||
let packageRoot: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Process-death recovery for managed extensions; no PID/age based lock stealing. */
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getCodeGraphDir } from '../directory';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { channel } from 'node:diagnostics_channel';
|
||||
@@ -10,7 +11,7 @@ import { sha256, packageDigest, trustedPackageDigest, type ExtensionPackage } fr
|
||||
|
||||
const context = new AsyncLocalStorage<string>();
|
||||
const transitions = channel('codegraph.extension.transaction');
|
||||
const dir = (root: string) => path.join(root, '.codegraph', 'plugins');
|
||||
const dir = (root: string) => path.join(getCodeGraphDir(root), '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]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
||||
@@ -102,7 +103,7 @@ function checkOwner(root: string, journal?: RecordV1): void {
|
||||
fail(root, 'torn/unknown owner record without a valid transaction; confirm no legacy installer is running before archiving the owner record');
|
||||
}
|
||||
function checkGraphOwner(root: string, transactionPid?: number): void {
|
||||
const lock = path.join(root, '.codegraph', 'codegraph.lock'), raw = read(lock)?.trim();
|
||||
const lock = path.join(getCodeGraphDir(root), 'codegraph.lock'), raw = read(lock)?.trim();
|
||||
if (raw === undefined) return;
|
||||
if (transactionPid && raw === String(transactionPid)) return; // orphan proved by the coordinator
|
||||
if (/^[1-9]\d*$/.test(raw)) {
|
||||
@@ -113,7 +114,7 @@ function checkGraphOwner(root: string, transactionPid?: number): void {
|
||||
fail(root, 'unknown legacy graph lock; confirm its owner has stopped before archiving codegraph.lock');
|
||||
}
|
||||
function marker(root: string, graphExisted: boolean): string | null {
|
||||
const file = path.join(root, '.codegraph', 'codegraph.db');
|
||||
const file = path.join(getCodeGraphDir(root), 'codegraph.db');
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const { db } = createDatabase(file);
|
||||
try {
|
||||
@@ -129,7 +130,7 @@ function replaceObserved(root: string, file: string, observed: string | null, ta
|
||||
if (target === null) remove(file); else durableWrite(file, target);
|
||||
}
|
||||
function cleanup(root: string, r: RecordV1, committed: boolean): void {
|
||||
const stage = path.join(root, '.codegraph', `extension-stage-${r.id}.db`);
|
||||
const stage = path.join(getCodeGraphDir(root), `extension-stage-${r.id}.db`);
|
||||
for (const suffix of ['', '-wal', '-shm', '.lock']) remove(stage + suffix);
|
||||
fs.rmSync(path.join(dir(root), `stage-${r.id}`), { recursive: true, force: true });
|
||||
if (!committed && r.package && !r.package.existed) {
|
||||
@@ -140,9 +141,9 @@ function cleanup(root: string, r: RecordV1, committed: boolean): void {
|
||||
// All modern graph writers use the coordinator. Only remove the killed
|
||||
// transaction's own old graph lock; never someone else's legacy lock.
|
||||
if (!committed && !r.graphExisted) {
|
||||
for (const suffix of ['', '-wal', '-shm']) remove(path.join(root, '.codegraph', 'codegraph.db') + suffix);
|
||||
for (const suffix of ['', '-wal', '-shm']) remove(path.join(getCodeGraphDir(root), 'codegraph.db') + suffix);
|
||||
}
|
||||
const graphLock = path.join(root, '.codegraph', 'codegraph.lock');
|
||||
const graphLock = path.join(getCodeGraphDir(root), 'codegraph.lock');
|
||||
if (read(graphLock)?.trim() === String(r.pid)) remove(graphLock);
|
||||
}
|
||||
/** Validate the version/trust side before removing the only recovery record. */
|
||||
@@ -229,7 +230,7 @@ export class ExtensionTransaction {
|
||||
readonly record: RecordV1;
|
||||
constructor(readonly root: string, nextConfig: string, pkg?: { contents: ExtensionPackage; integrity: string }) {
|
||||
if (fs.existsSync(recordPath(root))) throw new Error('An extension transaction is already active');
|
||||
this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(root, '.codegraph', 'codegraph.db')),
|
||||
this.record = { format: 1, id: randomUUID(), pid: process.pid, graphExisted: fs.existsSync(path.join(getCodeGraphDir(root), 'codegraph.db')),
|
||||
previousConfig: read(path.join(root, 'codegraph.json')), nextConfig,
|
||||
previousTrust: read(path.join(dir(root), 'trust.json')), nextTrust: read(path.join(dir(root), 'trust.json')),
|
||||
...(pkg ? { package: { integrity: pkg.integrity, existed: fs.existsSync(path.join(dir(root), 'packages', pkg.integrity)) } } : {}) };
|
||||
|
||||
Reference in New Issue
Block a user