Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/daemon-lifecycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ on:
branches: [fork/consolidated]
paths:
- 'src/mcp/**'
- 'src/runtime-control.ts'
- 'src/index.ts'
- 'src/extraction/**'
- 'src/codegraph.ts'
- 'codegraph-kernel/**'
- '__tests__/daemon-*.test.ts'
- '__tests__/mcp-*.test.ts'
- '__tests__/writer-lock.test.ts'
- '__tests__/runtime-control.test.ts'
- '__tests__/fixtures/runtime-control-daemon.cjs'
- '__tests__/writer-handover-ordering.test.ts'
- '__tests__/fixtures/writer-lock-ordering.cjs'
- '__tests__/query-pool.test.ts'
Expand Down Expand Up @@ -54,4 +58,4 @@ jobs:
- run: npm ci --ignore-scripts
- run: npm run build:kernel
- run: npm run build
- run: npx vitest run __tests__/daemon-install-check.test.ts __tests__/daemon-older-version.test.ts __tests__/cli-version.test.ts __tests__/daemon-registry.test.ts __tests__/daemon-pid-reuse.test.ts __tests__/daemon-lock-refresh.test.ts __tests__/mcp-daemon.test.ts __tests__/daemon-client-liveness.test.ts __tests__/daemon-bind-failure.test.ts __tests__/daemon-socket-fallback.test.ts __tests__/daemon-rendezvous-case.test.ts __tests__/writer-lock.test.ts __tests__/writer-handover-ordering.test.ts __tests__/mcp-writer-lock.test.ts __tests__/mcp-projectpath-lifecycle.test.ts __tests__/query-pool.test.ts __tests__/status-index-health.test.ts --maxWorkers=2 --minWorkers=1
- run: npx vitest run __tests__/runtime-control.test.ts __tests__/daemon-install-check.test.ts __tests__/daemon-older-version.test.ts __tests__/cli-version.test.ts __tests__/daemon-registry.test.ts __tests__/daemon-pid-reuse.test.ts __tests__/daemon-lock-refresh.test.ts __tests__/mcp-daemon.test.ts __tests__/daemon-client-liveness.test.ts __tests__/daemon-bind-failure.test.ts __tests__/daemon-socket-fallback.test.ts __tests__/daemon-rendezvous-case.test.ts __tests__/writer-lock.test.ts __tests__/writer-handover-ordering.test.ts __tests__/mcp-writer-lock.test.ts __tests__/mcp-projectpath-lifecycle.test.ts __tests__/query-pool.test.ts __tests__/status-index-health.test.ts --maxWorkers=2 --minWorkers=1
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Installers can verify daemon readiness and safely hand writer ownership across build promotion and rollback through a supported runtime-control API.

- `codegraph_explore` now finds quoted prose in script strings and template text through a capped source scan, ignoring case and punctuation without requiring a re-index.

- After a complete scan of indexed source without skipped files, `codegraph_explore` lists query names it cannot find, so an agent can catch a guessed name.
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,17 @@ Lower-level building blocks are exported from the same entry point for callers
that drive the graph directly: `DatabaseConnection`, `QueryBuilder`,
`getDatabasePath`, `initGrammars` / `loadGrammarsForLanguages`, and `FileLock`.

### Installer runtime control

Installers can use the supported `dist/runtime-control.js` module without loading
the graph API. Its functions are also exported from the package entry point.
`RUNTIME_CONTROL_PROTOCOL` is `1`. The API verifies daemon identity and readiness,
reports stop outcomes, and reserves the writer slot for a live updater. A
replacement bootstrap claims that reservation before running its CLI in the same
process. Keep the coordination module available separately from the executable
being replaced, including during rollback. Live legacy writers require explicit
session quiescence before cutover. See the [runtime control contract](site/src/content/docs/reference/api.md#installer-runtime-control).

**Embedding requirements**

- Install from npm (`npm i @colbymchenry/codegraph`) so the matching
Expand Down
15 changes: 15 additions & 0 deletions __tests__/fixtures/runtime-control-daemon.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const [root, socketPath, protocol, writerModule] = process.argv.slice(2);
const writer = require(writerModule);
writer.tryAcquireWriterLock(root, 'daemon');
writer.markWriterReady(root);
fs.writeFileSync(path.join(root, '.codegraph', 'daemon.pid'), JSON.stringify({pid:process.pid,version:'test-build',socketPath,startedAt:Date.now()}));
const server = net.createServer(socket => socket.end(JSON.stringify({protocol:1,pid:process.pid,codegraph:'test-build',...(protocol === 'legacy' ? {} : {writerProtocol:1})}) + '\n'));
server.listen(socketPath, () => process.send('ready'));
process.on('SIGTERM', () => {
writer.releaseWriterLock(root);
fs.rmSync(path.join(root, '.codegraph', 'daemon.pid'), {force:true});
server.close(() => process.exit(0));
});
213 changes: 213 additions & 0 deletions __tests__/runtime-control.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import {
getRuntimeIdentity, checkRuntimeReady, reserveRuntimeWriter, claimRuntimeWriter, releaseRuntimeWriter,
stopRuntime, type WriterReservation,
} from '../src/runtime-control';
import { readWriterLock, releaseWriterLock, markWriterReady, tryAcquireWriterLock } from '../src/mcp/writer-lock';
import { ToolHandler } from '../src/mcp/tools';
import { getDaemonPidPath, getDaemonSocketPath, encodeLockInfo } from '../src/mcp/daemon-paths';

let root: string;
let server: net.Server | null = null;
let statusProjectPath: unknown;
const actors: ChildProcess[] = [];
beforeEach(() => {
statusProjectPath = null;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-runtime-'));
fs.mkdirSync(path.join(root, '.codegraph'));
});
afterEach(async () => {
for (const actor of actors.splice(0)) {
if (actor.exitCode === null && actor.signalCode === null) {
const exited = once(actor, 'exit');
actor.kill('SIGKILL');
await exited;
}
}
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
releaseWriterLock(root);
fs.rmSync(root, { recursive: true, force: true });
});

describe('runtime writer reservations', () => {
it('hands a serialized updater reservation to a bootstrap process without freeing the slot', () => {
const reservation = reserveRuntimeWriter(root, process.pid)!;
expect(reservation).not.toBeNull();
expect(reserveRuntimeWriter(root, process.pid)).toBeNull();
const actor = spawnSync(process.execPath, ['-e', `
const api = require(process.argv[1]);
const writer = require(process.argv[2]);
const root = process.argv[3], lease = JSON.parse(process.argv[4]);
if (!api.claimRuntimeWriter(root, lease)) process.exit(2);
// Both the deployed legacy CLI and current daemon accept an existing own-PID record.
const acquired = writer.tryAcquireWriterLock(root, 'daemon');
console.log(JSON.stringify({pid:process.pid, acquired:acquired.kind, info:writer.readWriterLock(root)}));
`, path.resolve(__dirname, '../dist/runtime-control.js'), path.resolve(__dirname, '../dist/mcp/writer-lock.js'), root, JSON.stringify(reservation)], { encoding: 'utf8', timeout: 10000 });
expect(actor.status, actor.stderr).toBe(0);
const result = JSON.parse(actor.stdout);
expect(result.acquired).toBe('acquired');
expect(result.info).toMatchObject({ pid: result.pid, mode: 'daemon', ready: false });
expect(releaseRuntimeWriter(root, reservation)).toBe(true);
expect(readWriterLock(root)?.pid).toBe(result.pid);
});

it('does not let a released lease claim or remove another reservation by the same PID', () => {
const previous = reserveRuntimeWriter(root, process.pid)!;
expect(releaseRuntimeWriter(root, previous)).toBe(true);
const next = reserveRuntimeWriter(root, process.pid)!;
expect(claimRuntimeWriter(root, previous)).toBe(false);
expect(releaseRuntimeWriter(root, previous)).toBe(true);
expect(readWriterLock(root)).toMatchObject(next);
expect(claimRuntimeWriter(root, next)).toBe(true);
markWriterReady(root);
expect(readWriterLock(root)).toMatchObject({ pid: process.pid, mode: 'daemon', ready: true });
});

it('reports an unreadable reservation as incomplete release, preserving the record', () => {
const alias = path.join(root, 'alias');
fs.symlinkSync(root, alias, 'junction');
const actor = spawnSync(process.execPath, ['-e', `
const fs = require('node:fs'), path = require('node:path');
const api = require(process.argv[1]), root = process.argv[2];
const lease = api.reserveRuntimeWriter(root, process.pid);
const lock = fs.realpathSync.native(path.join(root, '.codegraph', 'writer.pid'));
const original = fs.readFileSync;
const before = original(lock, 'utf8');
let injectedReads = 0;
fs.readFileSync = function(file, ...args) {
if (file === lock) {
injectedReads++;
throw Object.assign(new Error('injected read failure'), {code:'EIO'});
}
return original.call(this, file, ...args);
};
const released = api.releaseRuntimeWriter(root, lease);
fs.readFileSync = original;
console.log(JSON.stringify({released, injectedReads, unchanged:original(lock, 'utf8') === before}));
`, path.resolve(__dirname, '../dist/runtime-control.js'), alias], { encoding: 'utf8', timeout: 10000 });
expect(actor.status, actor.stderr).toBe(0);
const { injectedReads, ...result } = JSON.parse(actor.stdout);
expect(result).toEqual({ released: false, unchanged: true });
expect(injectedReads).toBe(1);
});

it('rejects a dead updater before creating ownership', () => {
const actor = spawnSync(process.execPath, ['-e', 'console.log(process.pid)'], { encoding: 'utf8' });
expect(actor.status).toBe(0);
expect(() => reserveRuntimeWriter(root, Number(actor.stdout))).toThrow('live holder PID');
expect(readWriterLock(root)).toBeNull();
});

it('preserves ownership across every four-event ordering of stale release, claim, reservation and readiness', () => {
const events = ['reserve', 'claim', 'release', 'old-release', 'ready', 'fallback'] as const;
const failures: string[] = [];
let protectedSuccessors = 0;
function check(sequence: typeof events[number][]): void {
releaseWriterLock(root);
let lease: WriterReservation | null = null;
let old: WriterReservation | null = null;
for (const event of sequence) {
const before = readWriterLock(root);
if (event === 'reserve') {
const candidate = reserveRuntimeWriter(root, process.pid);
if (candidate) { if (lease) old = lease; lease = candidate; }
} else if (event === 'claim' && lease) claimRuntimeWriter(root, lease);
else if (event === 'release' && lease) { releaseRuntimeWriter(root, lease); old = lease; lease = null; }
else if (event === 'old-release' && old) releaseRuntimeWriter(root, old);
else if (event === 'ready') markWriterReady(root);
else if (event === 'fallback') tryAcquireWriterLock(root, 'fallback');
const after = readWriterLock(root);
if (event === 'old-release' && old && before && before.mode !== old.mode) {
protectedSuccessors++;
if (JSON.stringify(before) !== JSON.stringify(after)) failures.push(sequence.join(' -> '));
}
if (event === 'fallback' && before && after?.mode !== before.mode) failures.push(sequence.join(' -> '));
}
}
for (const a of events) for (const b of events) for (const c of events) for (const d of events) check([a, b, c, d]);
expect(protectedSuccessors).toBeGreaterThan(0);
expect(failures).toEqual([]);
});
});

async function fakeDaemon(options: { wrongHello?: boolean; wrongBuild?: boolean; statusError?: boolean; guidance?: boolean; closeEarly?: boolean; legacy?: boolean; changeRecord?: boolean } = {}): Promise<void> {
const guidance = options.guidance ? await new ToolHandler(null).execute('codegraph_status', {}) : null;
const socketPath = getDaemonSocketPath(root);
const info = { pid: process.pid, version: 'test-build', socketPath, startedAt: Date.now() };
fs.writeFileSync(getDaemonPidPath(root), encodeLockInfo(info));
server = net.createServer((socket) => {
socket.setEncoding('utf8');
socket.write(JSON.stringify({ protocol: 1, pid: process.pid, codegraph: options.wrongHello ? 'other' : 'test-build', ...(options.legacy ? {} : { writerProtocol: 1 }) }) + '\n');
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk;
let end: number;
while ((end = buffer.indexOf('\n')) >= 0) {
const msg = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1);
if (msg.method === 'initialize') {
if (options.closeEarly) { socket.end(); return; }
socket.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { serverInfo: { version: options.wrongBuild ? 'other' : 'test-build' } } }) + '\n');
} else if (msg.id === 2) {
statusProjectPath = msg.params?.arguments?.projectPath;
if (options.changeRecord) fs.writeFileSync(getDaemonPidPath(root), encodeLockInfo({ ...info, version: 'other' }));
socket.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: { isError: !!options.statusError, content: [{ type: 'text', text: options.guidance ? guidance!.content[0].text : '**CodeGraph Status**\n**Server build:** test-build\n**Files indexed:** 1\n**Total nodes:** 2\n**Total edges:** 1' }] } }) + '\n');
}
}
});
});
await new Promise<void>((resolve, reject) => { server!.once('error', reject); server!.listen(socketPath, resolve); });
}

describe('runtime identity and readiness', () => {
it('verifies a daemon build through hello, MCP initialization and status', async () => {
await fakeDaemon();
expect(await getRuntimeIdentity(root)).toEqual({ pid: process.pid, version: 'test-build' });
expect(await checkRuntimeReady(root, { pid: process.pid, version: 'test-build' }, 1000)).toEqual({ pid: process.pid, version: 'test-build' });
});

it('resolves a relative caller root before sending status to a daemon with another working directory', async () => {
await fakeDaemon();
await checkRuntimeReady(path.relative(process.cwd(), root), { pid: process.pid, version: 'test-build' }, 1000);
expect(path.isAbsolute(statusProjectPath as string)).toBe(true);
expect(fs.realpathSync.native(statusProjectPath as string)).toBe(fs.realpathSync.native(root));
});

it.each(['wrongHello', 'wrongBuild', 'statusError', 'guidance', 'closeEarly', 'changeRecord'] as const)(
'refuses readiness when %s invalidates the expected daemon', async (failure) => {
await fakeDaemon({ [failure]: true });
await expect(checkRuntimeReady(root, { pid: process.pid, version: 'test-build' }, 1000)).rejects.toThrow();
},
);

it('preserves a legacy daemon by default, then stops it only after explicit quiescence', async () => {
const actor = spawn(process.execPath, [path.resolve(__dirname, 'fixtures/runtime-control-daemon.cjs'),
root, getDaemonSocketPath(root), 'legacy', path.resolve(__dirname, '../dist/mcp/writer-lock.js')],
{ stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
actors.push(actor);
await once(actor, 'message');
const before = fs.readFileSync(getDaemonPidPath(root), 'utf8');
expect(await stopRuntime(root)).toMatchObject({ pid: actor.pid, outcome: 'legacy-writer' });
expect(actor.exitCode).toBeNull();
expect(fs.readFileSync(getDaemonPidPath(root), 'utf8')).toBe(before);
const exited = once(actor, 'exit');
expect(await stopRuntime(root, { legacyQuiescent: true })).toMatchObject({ pid: actor.pid, outcome: 'term' });
await exited;
const lease = reserveRuntimeWriter(root, process.pid);
expect(lease).not.toBeNull();
expect(readWriterLock(root)).toMatchObject(lease!);
});

it('preserves an unverified live daemon rather than authorizing an artifact swap', async () => {
await fakeDaemon({ wrongHello: true });
const before = fs.readFileSync(getDaemonPidPath(root), 'utf8');
expect(await stopRuntime(root)).toMatchObject({ pid: process.pid, outcome: 'unverified' });
expect(fs.readFileSync(getDaemonPidPath(root), 'utf8')).toBe(before);
});
});
46 changes: 45 additions & 1 deletion site/src/content/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: API
description: Use CodeGraph as a TypeScript library.
---

CodeGraph ships a TypeScript API. The public surface is the `CodeGraph` class.
CodeGraph ships a TypeScript API. The package exports the `CodeGraph` class, storage primitives and installer runtime control.

```typescript
import CodeGraph from '@colbymchenry/codegraph';
Expand Down Expand Up @@ -68,3 +68,47 @@ import {
- **Install from npm** (`npm i @colbymchenry/codegraph`) so the matching per-platform package — which carries the compiled library — is fetched alongside the shim.
- The API runs on **your** runtime, so it needs **Node 22.13+** or **Bun 1.4.0+** for the built-in `node:sqlite` module (an Electron main process qualifies when its bundled Node is 22.13+). The CLI and MCP server are unaffected — they ship with a self-contained bundled runtime and need no Node at all.
- TypeScript types ship with the package. Keep `@types/node` available and `skipLibCheck: true` (the common default).

## Installer runtime control

Installers may load the supported `dist/runtime-control.js` module from a
validated build. The same functions are exported from the package entry point.
`RUNTIME_CONTROL_PROTOCOL` is `1`.

| Function | Result |
|---|---|
| `getRuntimeIdentity(root)` | Hello-verified `{pid, version}`, or `null` |
| `stopRuntime(root, options?)` | `{root, pid, outcome, version?}` with verified signalling |
| `reserveRuntimeWriter(root, holderPid)` | Opaque JSON reservation for a live updater, or `null` when the slot is occupied |
| `claimRuntimeWriter(root, reservation)` | `true` when this bootstrap process claims that exact reservation |
| `releaseRuntimeWriter(root, reservation)` | `true` when the reservation is gone; preserves successor ownership |
| `checkRuntimeReady(root, identity, timeoutMs?)` | The expected identity after hello, MCP initialization and status verification |

Only `term`, `kill`, `not-running` and `no-daemon` stop outcomes allow a
reservation attempt. `unverified`, `still-running` and `legacy-writer` require
standing down. A successful stop does not itself reserve the slot. Reserve
before moving artifacts, and stand down if another writer has claimed it.

By default, stop preserves a live daemon without writer coordination protocol
`1`. Set `{legacyQuiescent: true}` only after confirming all legacy MCP sessions
and direct or fallback writers for that project are stopped. Daemon death alone
does not establish that quiescence. A restored legacy daemon needs the same
explicit cutover before another promotion.

Keep the reservation value unchanged. Its generation distinguishes successive
reservations held by one PID. Claim it in the daemon bootstrap, then run the
selected CLI entry in that same process. Spawning another CLI child after claim
would hand ownership to the wrong PID. The bootstrap must use the validated
coordination module even when rollback selects an older CLI.

Keep `coordinationDir` separate from `executableDir`. Retain the coordination
build and its native kernel until promotion or rollback has finished. Host
process launch, service limits, artifact swaps and rollback policy remain with
the installer. Never reconstruct writer-file mutations in the adapter.

Release is idempotent and preserves a successor. A `false` result means release
could not complete; retry within a bounded deadline and report failure rather
than claiming cleanup succeeded. For example, retry every 100 ms for five
seconds. Do not move artifacts or start another replacement after failed claim,
unverified identity or failed readiness. Readiness rejects a changed build,
failed status, expected-condition guidance, closed socket or timeout; its default timeout is 120 seconds.
Loading
Loading