graphlin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/adapters/README.md +32 -0
- package/adapters/claude/hooks.json +10 -0
- package/adapters/claude/profile.json +18 -0
- package/adapters/codex/hooks.json +9 -0
- package/adapters/codex/profile.json +22 -0
- package/adapters/kiro/profile.json +8 -0
- package/mcp.json +11 -0
- package/package.json +114 -0
- package/plugin.json +20 -0
- package/runtime/collector/index.mjs +23 -0
- package/runtime/core/candidates.mjs +300 -0
- package/runtime/core/common.mjs +69 -0
- package/runtime/core/evidence.mjs +150 -0
- package/runtime/core/graph.mjs +398 -0
- package/runtime/core/index.mjs +4 -0
- package/runtime/core/lexical.mjs +255 -0
- package/runtime/core/privacy.mjs +206 -0
- package/runtime/core/tool-discovery.mjs +122 -0
- package/runtime/daemon/auth.mjs +50 -0
- package/runtime/daemon/connection-info.mjs +249 -0
- package/runtime/daemon/demo.mjs +195 -0
- package/runtime/daemon/diagnostics.mjs +404 -0
- package/runtime/daemon/export.mjs +7 -0
- package/runtime/daemon/ipc.mjs +28 -0
- package/runtime/daemon/lock.mjs +137 -0
- package/runtime/daemon/manager.mjs +320 -0
- package/runtime/daemon/paths.mjs +108 -0
- package/runtime/daemon/persistence.mjs +64 -0
- package/runtime/daemon/server.mjs +292 -0
- package/runtime/daemon/settings.mjs +103 -0
- package/runtime/jev/fixture.mjs +99 -0
- package/runtime/jev/index.mjs +784 -0
- package/runtime/jev/questions.mjs +268 -0
- package/runtime/jev/wire.mjs +152 -0
- package/runtime/pipeline.mjs +1071 -0
- package/runtime/web/app.js +2596 -0
- package/runtime/web/index.html +265 -0
- package/runtime/web/layout.js +336 -0
- package/runtime/web/sidebar.js +525 -0
- package/runtime/web/sketch.js +347 -0
- package/runtime/web/style.css +593 -0
- package/schemas/bundle.schema.json +243 -0
- package/schemas/event.schema.json +108 -0
- package/schemas/graph.schema.json +449 -0
- package/schemas/patch.schema.json +111 -0
- package/scripts/arguments.mjs +37 -0
- package/scripts/build-packages.mjs +160 -0
- package/scripts/collect.sh +23 -0
- package/scripts/collector.mjs +11 -0
- package/scripts/control.mjs +80 -0
- package/scripts/daemon.mjs +28 -0
- package/scripts/graphlin.mjs +112 -0
- package/scripts/onboarding.mjs +413 -0
- package/scripts/validate-packages.mjs +118 -0
- package/skills/graphlin/SKILL.md +103 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdir, rename, rm, lstat, opendir } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { atomicJSON, privateDirectory, readPrivateJSON, runtimeError, uid, PROTOCOL } from './paths.mjs';
|
|
5
|
+
import { requestIPC } from './ipc.mjs';
|
|
6
|
+
|
|
7
|
+
function alive(pid) {
|
|
8
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return true;
|
|
9
|
+
try { process.kill(pid, 0); return true; }
|
|
10
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
const CLAIM_NAME = /^([1-9]\d*)-([0-9a-f-]{36})$/;
|
|
15
|
+
const busy = () => runtimeError('daemon_busy');
|
|
16
|
+
const absent = error => { if (error.code !== 'ENOENT') throw error; return null; };
|
|
17
|
+
|
|
18
|
+
// Bakery tickets serialize publication and removal, not the daemon lifetime.
|
|
19
|
+
// A unique directory registers "choosing" before any ticket is read or written.
|
|
20
|
+
// Its PID is in its name, so even a crash before writing the ticket is recoverable.
|
|
21
|
+
// Unlike a fixed reaping guard, deleting a dead claim can never delete a successor.
|
|
22
|
+
async function claims(directory) {
|
|
23
|
+
const result = [];
|
|
24
|
+
let scanned = 0;
|
|
25
|
+
for await (const entry of await opendir(directory)) {
|
|
26
|
+
if (++scanned > 1024) throw busy();
|
|
27
|
+
const match = CLAIM_NAME.exec(entry.name);
|
|
28
|
+
if (!match || !entry.isDirectory()) throw busy();
|
|
29
|
+
const filename = path.join(directory, entry.name), pid = Number(match[1]);
|
|
30
|
+
if (!alive(pid)) {
|
|
31
|
+
await rm(filename, { recursive: true, force: true });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const ticketPath = path.join(filename, 'ticket.json');
|
|
35
|
+
const ticket = await readPrivateJSON(ticketPath, 4096).catch(async error => {
|
|
36
|
+
// A departing peer can unlink an already-open ticket before fstat;
|
|
37
|
+
// readPrivateJSON then correctly rejects its zero link count. Treat
|
|
38
|
+
// only a now-absent ticket as a choosing/departing peer, never as ready.
|
|
39
|
+
if (error.code === 'ENOENT' || !await lstat(ticketPath).catch(absent)) return null;
|
|
40
|
+
throw busy();
|
|
41
|
+
});
|
|
42
|
+
if (ticket && (ticket.id !== entry.name || !Number.isSafeInteger(ticket.number) || ticket.number < 1)) throw busy();
|
|
43
|
+
// A peer may have finished between directory enumeration and reading.
|
|
44
|
+
if (!ticket && !await lstat(filename).catch(absent)) continue;
|
|
45
|
+
result.push({ id: entry.name, number: ticket?.number ?? null });
|
|
46
|
+
if (result.length > 64) throw busy();
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function withPublicationGuard(paths, action) {
|
|
52
|
+
const directory = `${paths.lock}.claims`;
|
|
53
|
+
await privateDirectory(directory);
|
|
54
|
+
const id = `${process.pid}-${randomUUID()}`, claim = path.join(directory, id);
|
|
55
|
+
await mkdir(claim, { mode: 0o700 });
|
|
56
|
+
try {
|
|
57
|
+
const peers = await claims(directory);
|
|
58
|
+
const number = 1 + Math.max(0, ...peers.map(peer => peer.number ?? 0));
|
|
59
|
+
if (!Number.isSafeInteger(number)) throw busy();
|
|
60
|
+
await atomicJSON(path.join(claim, 'ticket.json'), { id, number }, 4096);
|
|
61
|
+
const deadline = Date.now() + 2000;
|
|
62
|
+
while (true) {
|
|
63
|
+
const pending = (await claims(directory)).some(peer => peer.id !== id &&
|
|
64
|
+
(peer.number === null || peer.number < number || (peer.number === number && peer.id < id)));
|
|
65
|
+
if (!pending) return await action(claim);
|
|
66
|
+
if (Date.now() >= deadline) throw busy();
|
|
67
|
+
await wait(20);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(claim, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function existingOwner(paths) {
|
|
75
|
+
const info = await lstat(paths.lock).catch(absent);
|
|
76
|
+
if (!info) return null;
|
|
77
|
+
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) ||
|
|
78
|
+
(uid() !== undefined && info.uid !== uid())) throw busy();
|
|
79
|
+
const owner = await readPrivateJSON(path.join(paths.lock, 'owner.json'), 4096).catch(error => {
|
|
80
|
+
if (error.code !== 'ENOENT') throw busy();
|
|
81
|
+
return null;
|
|
82
|
+
});
|
|
83
|
+
if (owner) {
|
|
84
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1 || typeof owner.instanceId !== 'string') throw busy();
|
|
85
|
+
return owner;
|
|
86
|
+
}
|
|
87
|
+
// Legacy versions could expose an empty directory before owner.json was
|
|
88
|
+
// committed. Respect any complete live owner in an interrupted temp write.
|
|
89
|
+
let scanned = 0;
|
|
90
|
+
for await (const entry of await opendir(paths.lock)) {
|
|
91
|
+
if (++scanned > 64 || !/^owner\.json\.[0-9a-f-]+\.tmp$/.test(entry.name) || !entry.isFile()) throw busy();
|
|
92
|
+
const partial = await readPrivateJSON(path.join(paths.lock, entry.name), 4096).catch(() => null);
|
|
93
|
+
if (partial && alive(partial.pid)) throw busy();
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function health(paths) {
|
|
99
|
+
const owner = await readPrivateJSON(path.join(paths.lock, 'owner.json'), 4096);
|
|
100
|
+
if (owner.protocol !== PROTOCOL || owner.projectId !== paths.projectId) throw runtimeError('invalid_owner');
|
|
101
|
+
const result = await requestIPC(paths.socket, { op: 'health', instanceId: owner.instanceId });
|
|
102
|
+
if (!result.ok || result.instanceId !== owner.instanceId || result.projectId !== paths.projectId ||
|
|
103
|
+
result.protocol !== PROTOCOL || result.pid !== owner.pid) throw runtimeError('invalid_daemon');
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function acquireLock(paths) {
|
|
108
|
+
const owner = { protocol: PROTOCOL, projectId: paths.projectId, pid: process.pid,
|
|
109
|
+
instanceId: randomUUID(), createdAt: Date.now() };
|
|
110
|
+
await withPublicationGuard(paths, async claim => {
|
|
111
|
+
const previous = await existingOwner(paths);
|
|
112
|
+
// Never kill or displace a live PID, including one without working IPC.
|
|
113
|
+
if (previous && alive(previous.pid)) {
|
|
114
|
+
try { await health(paths); } catch { throw busy(); }
|
|
115
|
+
throw runtimeError('already_running');
|
|
116
|
+
}
|
|
117
|
+
await rm(paths.lock, { recursive: true, force: true });
|
|
118
|
+
await rm(`${paths.lock}.reaping`, { recursive: true, force: true });
|
|
119
|
+
const publication = path.join(claim, 'publication');
|
|
120
|
+
await mkdir(publication, { mode: 0o700 });
|
|
121
|
+
await atomicJSON(path.join(publication, 'owner.json'), owner, 4096);
|
|
122
|
+
// The public lock first becomes visible with its complete owner record.
|
|
123
|
+
// A crash before this rename leaves only a uniquely named dead claim.
|
|
124
|
+
await rename(publication, paths.lock);
|
|
125
|
+
});
|
|
126
|
+
let releasing;
|
|
127
|
+
return {
|
|
128
|
+
owner,
|
|
129
|
+
release() {
|
|
130
|
+
releasing ??= withPublicationGuard(paths, async () => {
|
|
131
|
+
const current = await existingOwner(paths);
|
|
132
|
+
if (current?.instanceId === owner.instanceId) await rm(paths.lock, { recursive: true, force: true });
|
|
133
|
+
});
|
|
134
|
+
return releasing;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { projectPaths, defaultDataDir, MAX_STATE_BYTES, runtimeError, readPrivateJSON } from './paths.mjs';
|
|
5
|
+
import { health } from './lock.mjs';
|
|
6
|
+
import { requestIPC } from './ipc.mjs';
|
|
7
|
+
import { diagnosticArtifactId, readPersistedDiagnostics, DIAGNOSTIC_LIMITS } from './diagnostics.mjs';
|
|
8
|
+
import { readSettings, resolvePolicy } from './settings.mjs';
|
|
9
|
+
import { inspectInstalledPackages } from './connection-info.mjs';
|
|
10
|
+
|
|
11
|
+
const worker = fileURLToPath(new URL('../../scripts/daemon.mjs', import.meta.url));
|
|
12
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
13
|
+
const safeCodes = new Set(['already_running', 'daemon_busy', 'invalid_project', 'unsupported_platform',
|
|
14
|
+
'unsafe_data_directory', 'policy_restart_required', 'port_restart_required', 'port_in_use',
|
|
15
|
+
'daemon_start_failed', 'daemon_start_timeout', 'shutdown_failed', 'shutdown_pending',
|
|
16
|
+
'restart_required', 'diagnostics_unavailable', 'invalid_log_filter', 'unsafe_settings', 'invalid_settings', 'settings_busy']);
|
|
17
|
+
|
|
18
|
+
export function publicError(error) {
|
|
19
|
+
return safeCodes.has(error?.code) ? error.code : 'runtime_unavailable';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function daemonStatus({ projectRoot, dataDir } = {}) {
|
|
23
|
+
let paths;
|
|
24
|
+
try {
|
|
25
|
+
paths = await projectPaths(projectRoot, dataDir);
|
|
26
|
+
return { running: true, logPath: path.join(paths.directory, 'diagnostics.jsonl'), ...await health(paths) };
|
|
27
|
+
} catch { return { running: false, code: 'not_running',
|
|
28
|
+
...(paths ? { logPath: path.join(paths.directory, 'diagnostics.jsonl') } : {}) }; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateRuntime() {
|
|
32
|
+
if (Number(process.versions.node.split('.')[0]) < 22) throw runtimeError('unsupported_runtime');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateExisting(current, options) {
|
|
36
|
+
const { allowSource, persistEvidence, displayEvidence } = resolvePolicy(options, { current: current.policy });
|
|
37
|
+
const { mode = 'live', port = 0 } = options;
|
|
38
|
+
if (current.mode !== mode || current.policy.transmitSource !== Boolean(allowSource) ||
|
|
39
|
+
current.policy.persistEvidence !== Boolean(persistEvidence) ||
|
|
40
|
+
current.policy.displayEvidence !== Boolean(displayEvidence)) throw runtimeError('policy_restart_required');
|
|
41
|
+
if (port && port !== current.port) throw runtimeError('port_restart_required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function existingLaunch(paths, current, options) {
|
|
45
|
+
validateExisting(current, options);
|
|
46
|
+
const launch = await requestIPC(paths.socket, { op: 'launch', instanceId: current.instanceId });
|
|
47
|
+
if (!launch.ok || launch.instanceId !== current.instanceId) throw runtimeError('daemon_start_failed');
|
|
48
|
+
return { ...launch, reused: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitForExisting(paths, options, signal) {
|
|
52
|
+
const deadline = Date.now() + 8000;
|
|
53
|
+
while (Date.now() < deadline && !signal?.aborted) {
|
|
54
|
+
const current = await daemonStatus(paths);
|
|
55
|
+
if (current.running) return existingLaunch(paths, current, options);
|
|
56
|
+
await delay(80);
|
|
57
|
+
}
|
|
58
|
+
if (!signal?.aborted) throw runtimeError('daemon_busy');
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function ownerMatches(paths, instanceId) {
|
|
63
|
+
const owner = await readPrivateJSON(path.join(paths.lock, 'owner.json'), 4096).catch(() => null);
|
|
64
|
+
if (owner?.instanceId !== instanceId) return false;
|
|
65
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) return false;
|
|
66
|
+
try { process.kill(owner.pid, 0); return true; }
|
|
67
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function stopInstance(paths, instanceId) {
|
|
71
|
+
if (!await ownerMatches(paths, instanceId)) return { ok: true, stopped: false };
|
|
72
|
+
try {
|
|
73
|
+
const result = await requestIPC(paths.socket, { op: 'shutdown', instanceId });
|
|
74
|
+
if (!result.ok) return { ok: false, code: 'shutdown_pending' };
|
|
75
|
+
} catch {
|
|
76
|
+
if (!await ownerMatches(paths, instanceId)) return { ok: true, stopped: true };
|
|
77
|
+
return { ok: false, code: 'shutdown_pending' };
|
|
78
|
+
}
|
|
79
|
+
const deadline = Date.now() + 5000;
|
|
80
|
+
while (Date.now() < deadline) {
|
|
81
|
+
if (!await ownerMatches(paths, instanceId)) return { ok: true, stopped: true };
|
|
82
|
+
await delay(50);
|
|
83
|
+
}
|
|
84
|
+
return { ok: false, code: 'shutdown_pending' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Own a server in this process, or attach to the existing canonical instance.
|
|
89
|
+
* onReady receives the one-use URL once; this promise stays pending until that
|
|
90
|
+
* instance closes or the caller aborts. Abort never stops a replacement owner.
|
|
91
|
+
*/
|
|
92
|
+
export async function runForeground({ projectRoot, dataDir = defaultDataDir(), signal,
|
|
93
|
+
allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}, onReady = () => {}) {
|
|
94
|
+
validateRuntime();
|
|
95
|
+
if (signal?.aborted) return;
|
|
96
|
+
const paths = await projectPaths(projectRoot, dataDir);
|
|
97
|
+
const options = { allowSource, persistEvidence, displayEvidence, mode, port };
|
|
98
|
+
let server, launch, interrupted;
|
|
99
|
+
const interruption = new Promise(resolve => { interrupted = resolve; });
|
|
100
|
+
const interrupt = () => interrupted();
|
|
101
|
+
signal?.addEventListener('abort', interrupt, { once: true });
|
|
102
|
+
try {
|
|
103
|
+
const current = await daemonStatus(paths);
|
|
104
|
+
if (current.running) launch = await existingLaunch(paths, current, options);
|
|
105
|
+
else if (!signal?.aborted) {
|
|
106
|
+
const settings = mode === 'demo' ? {} : await readSettings(paths);
|
|
107
|
+
const policy = resolvePolicy(options, { saved: settings.policy });
|
|
108
|
+
await projectPaths(projectRoot, dataDir, { create: true });
|
|
109
|
+
const { startServer } = await import('./server.mjs');
|
|
110
|
+
const demo = mode === 'demo' ? await import('./demo.mjs') : null;
|
|
111
|
+
try {
|
|
112
|
+
server = await startServer({ projectRoot: paths.projectRoot, dataDir: paths.dataDir, mode, port,
|
|
113
|
+
policy: { transmitSource: policy.allowSource, persistEvidence: policy.persistEvidence,
|
|
114
|
+
displayEvidence: policy.displayEvidence },
|
|
115
|
+
apiKey: policy.allowSource ? process.env.TYPESAFE_API_KEY ?? settings.apiKey : undefined,
|
|
116
|
+
decisionService: demo?.demoDecisionService() });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (['daemon_busy', 'already_running'].includes(error.code)) launch = await waitForExisting(paths, options, signal);
|
|
119
|
+
else if (error.code === 'EADDRINUSE') throw runtimeError('port_in_use');
|
|
120
|
+
else throw error;
|
|
121
|
+
}
|
|
122
|
+
if (server && !signal?.aborted) {
|
|
123
|
+
launch = { ...await health(paths), url: server.url, reused: false };
|
|
124
|
+
if (demo) {
|
|
125
|
+
const replay = demo.replayDemo(server.pipeline, paths.projectRoot);
|
|
126
|
+
await Promise.race([replay, interruption]);
|
|
127
|
+
// Closing the pipeline cancels work; observe the replay's eventual
|
|
128
|
+
// rejection if a signal interrupted it before the ready announcement.
|
|
129
|
+
replay.catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!launch || signal?.aborted) return;
|
|
134
|
+
onReady({ ...launch, foreground: true });
|
|
135
|
+
if (server) {
|
|
136
|
+
const closed = await Promise.race([server.whenClosed, interruption]);
|
|
137
|
+
if (closed && !closed.ok) throw runtimeError('shutdown_failed');
|
|
138
|
+
} else {
|
|
139
|
+
while (!signal?.aborted && await ownerMatches(paths, launch.instanceId)) {
|
|
140
|
+
// Each poll settles independently; racing the same pending signal
|
|
141
|
+
// promise here would retain one callback per poll until interruption.
|
|
142
|
+
await delay(200);
|
|
143
|
+
}
|
|
144
|
+
if (signal?.aborted) {
|
|
145
|
+
const stopped = await stopInstance(paths, launch.instanceId);
|
|
146
|
+
if (!stopped.ok) throw runtimeError(stopped.code);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
signal?.removeEventListener('abort', interrupt);
|
|
151
|
+
await server?.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Detached operation is reserved for an explicit CLI --background request or
|
|
156
|
+
// MCP. The normal interactive CLI uses runForeground instead.
|
|
157
|
+
export async function startDaemon({ projectRoot, dataDir = defaultDataDir(), background = true,
|
|
158
|
+
allowSource, persistEvidence, displayEvidence, mode = 'live', port = 0 } = {}) {
|
|
159
|
+
validateRuntime();
|
|
160
|
+
if (background !== true) throw runtimeError('background_required');
|
|
161
|
+
const paths = await projectPaths(projectRoot, dataDir);
|
|
162
|
+
const options = { allowSource, persistEvidence, displayEvidence, mode, port };
|
|
163
|
+
const existing = await daemonStatus({ projectRoot: paths.projectRoot, dataDir: paths.dataDir });
|
|
164
|
+
if (existing.running) return { ...await existingLaunch(paths, existing, options), foreground: false };
|
|
165
|
+
const settings = mode === 'demo' ? {} : await readSettings(paths);
|
|
166
|
+
const policy = resolvePolicy(options, { saved: settings.policy });
|
|
167
|
+
await projectPaths(projectRoot, dataDir, { create: true });
|
|
168
|
+
const args = [worker, '--project', paths.projectRoot, '--data-dir', paths.dataDir, '--mode', mode, '--port', String(port)];
|
|
169
|
+
if (policy.allowSource) args.push('--allow-source');
|
|
170
|
+
if (policy.persistEvidence) args.push('--persist-evidence');
|
|
171
|
+
if (!policy.displayEvidence) args.push('--no-display-evidence');
|
|
172
|
+
const env = { ...process.env };
|
|
173
|
+
// A metadata-only or fixture daemon does not inherit the paid-service key.
|
|
174
|
+
if (!policy.allowSource || mode !== 'live') delete env.TYPESAFE_API_KEY;
|
|
175
|
+
else if (env.TYPESAFE_API_KEY === undefined && settings.apiKey) env.TYPESAFE_API_KEY = settings.apiKey;
|
|
176
|
+
const child = spawn(process.execPath, args, {
|
|
177
|
+
detached: true, stdio: 'ignore', env, cwd: paths.projectRoot,
|
|
178
|
+
});
|
|
179
|
+
let spawnFailed = false;
|
|
180
|
+
child.on('error', () => { spawnFailed = true; });
|
|
181
|
+
child.unref();
|
|
182
|
+
const deadline = Date.now() + 8000;
|
|
183
|
+
while (Date.now() < deadline) {
|
|
184
|
+
const current = await daemonStatus({ projectRoot: paths.projectRoot, dataDir: paths.dataDir });
|
|
185
|
+
if (current.running) return { ...await existingLaunch(paths, current, options),
|
|
186
|
+
reused: current.pid !== child.pid, foreground: false };
|
|
187
|
+
if (spawnFailed) throw runtimeError('daemon_start_failed');
|
|
188
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
189
|
+
// Another concurrent starter may have won the lock. Wait for that owner
|
|
190
|
+
// to finish starting rather than launching a second server or failing it.
|
|
191
|
+
const owner = await readPrivateJSON(path.join(paths.lock, 'owner.json'), 4096).catch(() => null);
|
|
192
|
+
if (!owner || owner.pid === child.pid) throw runtimeError('daemon_start_failed');
|
|
193
|
+
}
|
|
194
|
+
await delay(80);
|
|
195
|
+
}
|
|
196
|
+
child.kill('SIGTERM');
|
|
197
|
+
const escalation = setTimeout(() => { child.kill('SIGKILL'); }, 250);
|
|
198
|
+
child.once('exit', () => clearTimeout(escalation));
|
|
199
|
+
throw runtimeError('daemon_start_timeout');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function stopDaemon({ projectRoot, dataDir } = {}) {
|
|
203
|
+
const paths = await projectPaths(projectRoot, dataDir);
|
|
204
|
+
const current = await daemonStatus({ projectRoot, dataDir });
|
|
205
|
+
if (!current.running) return { ok: true, stopped: false };
|
|
206
|
+
return stopInstance(paths, current.instanceId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function exportDaemon({ projectRoot, dataDir } = {}) {
|
|
210
|
+
const paths = await projectPaths(projectRoot, dataDir);
|
|
211
|
+
const current = await health(paths);
|
|
212
|
+
const response = await requestIPC(paths.socket, { op: 'export', instanceId: current.instanceId },
|
|
213
|
+
{ maxResponseBytes: MAX_STATE_BYTES + 4096 });
|
|
214
|
+
if (!response.ok) throw runtimeError('export_unavailable');
|
|
215
|
+
return response.snapshot;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function diagnosticLogs({ projectRoot, dataDir, file } = {}) {
|
|
219
|
+
const paths = await projectPaths(projectRoot, dataDir);
|
|
220
|
+
const artifactId = file === undefined ? undefined : diagnosticArtifactId(paths.projectRoot, file, projectRoot);
|
|
221
|
+
let current;
|
|
222
|
+
try { current = await health(paths); } catch { /* A stopped project can read its private retained log. */ }
|
|
223
|
+
if (!current) return readPersistedDiagnostics(paths, { artifactId });
|
|
224
|
+
const response = await requestIPC(paths.socket, { op: 'diagnostics', instanceId: current.instanceId, artifactId },
|
|
225
|
+
{ maxResponseBytes: DIAGNOSTIC_LIMITS.ringBytes + 64 * 1024, timeoutMs: 1500 });
|
|
226
|
+
if (!response.ok) throw runtimeError(response.code === 'invalid_operation' ? 'restart_required' : 'diagnostics_unavailable');
|
|
227
|
+
if (response.schemaVersion !== 1 || !Array.isArray(response.records) || !response.stats) {
|
|
228
|
+
throw runtimeError('restart_required');
|
|
229
|
+
}
|
|
230
|
+
const { ok, ...envelope } = response;
|
|
231
|
+
return envelope;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function version(command) {
|
|
235
|
+
return new Promise(resolve => {
|
|
236
|
+
const env = { ...process.env };
|
|
237
|
+
delete env.TYPESAFE_API_KEY;
|
|
238
|
+
const child = spawn(command, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env });
|
|
239
|
+
let stdout = '', bytes = 0, finished = false, terminating = false, escalation;
|
|
240
|
+
const deadline = setTimeout(terminate, 1000);
|
|
241
|
+
function finish(value) {
|
|
242
|
+
if (finished) return;
|
|
243
|
+
finished = true;
|
|
244
|
+
clearTimeout(deadline); clearTimeout(escalation);
|
|
245
|
+
child.stdout.destroy();
|
|
246
|
+
resolve(value);
|
|
247
|
+
}
|
|
248
|
+
function terminate() {
|
|
249
|
+
if (finished || terminating) return;
|
|
250
|
+
terminating = true;
|
|
251
|
+
child.kill('SIGTERM');
|
|
252
|
+
escalation = setTimeout(() => {
|
|
253
|
+
child.kill('SIGKILL');
|
|
254
|
+
// Do not wait indefinitely for an inherited stdout pipe to close.
|
|
255
|
+
finish(null);
|
|
256
|
+
}, 200);
|
|
257
|
+
}
|
|
258
|
+
child.on('error', () => finish(null));
|
|
259
|
+
child.stdout.on('error', () => terminate());
|
|
260
|
+
child.stdout.on('data', chunk => {
|
|
261
|
+
bytes += chunk.length;
|
|
262
|
+
if (bytes > 4096) { terminate(); return; }
|
|
263
|
+
stdout += chunk.toString('utf8');
|
|
264
|
+
});
|
|
265
|
+
child.on('close', code => {
|
|
266
|
+
const found = !terminating && code === 0 && stdout.match(/\b\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?/);
|
|
267
|
+
finish(found ? found[0] : null);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function doctor({ projectRoot, dataDir } = {}) {
|
|
273
|
+
const [status, claude, codex, kiro, settingsResult] = await Promise.all([
|
|
274
|
+
daemonStatus({ projectRoot, dataDir }), version('claude'), version('codex'), version('kiro'),
|
|
275
|
+
readSettings({ projectRoot, dataDir }).then(value => ({ value }), () => ({ error: 'unsafe_settings' })),
|
|
276
|
+
]);
|
|
277
|
+
const settings = settingsResult.value ?? {};
|
|
278
|
+
const packages = settings.installation ? await inspectInstalledPackages({
|
|
279
|
+
dataDir: (await projectPaths(projectRoot, dataDir)).dataDir, version: settings.installation.version,
|
|
280
|
+
}).catch(() => ({ claude: false, codex: false })) : {};
|
|
281
|
+
const credential = (process.env.TYPESAFE_API_KEY ?? settings.apiKey) ? 'configured_not_verified' : 'missing';
|
|
282
|
+
const received = status.running ? status.observations?.hooks ?? {} : {};
|
|
283
|
+
const hosts = Object.fromEntries(Object.entries({ claude, codex }).map(([host, hostVersion]) => [host, {
|
|
284
|
+
version: hostVersion,
|
|
285
|
+
installation: settings.installation?.hosts.includes(host) ? 'recorded' : 'not_recorded',
|
|
286
|
+
setupPending: settings.installation?.pendingHosts?.includes(host) ?? false,
|
|
287
|
+
packageFiles: packages[host] ? 'verified' : 'not_verified',
|
|
288
|
+
activation: received[host] > 0 ? 'hook_observed' : 'not_verified',
|
|
289
|
+
receivedHooks: received[host] ?? 0,
|
|
290
|
+
}]));
|
|
291
|
+
const policy = resolvePolicy({}, { current: status.running ? status.policy : undefined, saved: settings.policy });
|
|
292
|
+
const nextActions = [];
|
|
293
|
+
if (settingsResult.error) nextActions.push('Settings could not be safely read. Check permissions on the Graphlin data directory.');
|
|
294
|
+
if (!settings.installation?.hosts.length) nextActions.push('Run graphlin init in this project to install an agent plugin.');
|
|
295
|
+
if (settings.installation?.pendingHosts?.length) nextActions.push('Agent setup is incomplete. Run graphlin again to resume the requested installations.');
|
|
296
|
+
else if (settings.installation?.hosts.some(host => !packages[host])) {
|
|
297
|
+
nextActions.push('Installed package files are missing or invalid. Run graphlin init to repair the installation.');
|
|
298
|
+
}
|
|
299
|
+
if (!status.running) nextActions.push('Run graphlin in this project and keep that terminal open.');
|
|
300
|
+
if (!policy.allowSource) nextActions.push('Architecture classification needs source-sharing consent. Run graphlin init to choose it.');
|
|
301
|
+
if (policy.allowSource && credential === 'missing') nextActions.push('Run graphlin init to save your TypeSafe key at its hidden prompt.');
|
|
302
|
+
if (['unavailable', 'timeout'].includes(status.status?.classifier)) {
|
|
303
|
+
nextActions.push('Run graphlin logs for the classifier failure reason. If authentication failed, run graphlin init --replace-key, then restart Graphlin.');
|
|
304
|
+
}
|
|
305
|
+
if (status.running && !Object.values(received).some(count => count > 0)) {
|
|
306
|
+
nextActions.push('Start Claude Code or Codex with Graphlin installed, review its hook permissions, and ask it to explore this project.');
|
|
307
|
+
} else if (status.running && !status.observations?.shapes) {
|
|
308
|
+
nextActions.push('Ask your agent to read the main source files. Use graphlin logs to see why observations have not produced shapes.');
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
runtime: { node: process.versions.node, supported: Number(process.versions.node.split('.')[0]) >= 22,
|
|
312
|
+
platform: process.platform, privateIPC: process.platform !== 'win32' },
|
|
313
|
+
hosts: { ...hosts,
|
|
314
|
+
kiro: { version: kiro, activation: 'inactive_experimental' } },
|
|
315
|
+
daemon: status,
|
|
316
|
+
coverage: Object.values(received).some(count => count > 0) ? 'hook_delivery_observed' : 'host_activation_unverified',
|
|
317
|
+
credential, settings: settingsResult.error ?? 'readable', nextActions,
|
|
318
|
+
note: 'No remote credential check was sent. An observed hook confirms delivery, not every host permission or runtime connectivity.',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, realpath, lstat, open, rename, rm, chmod } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const PROTOCOL = 1;
|
|
8
|
+
export const MAX_IPC_BYTES = 256 * 1024;
|
|
9
|
+
export const MAX_STATE_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const uid = () => process.getuid?.();
|
|
11
|
+
export const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
12
|
+
export const runtimeError = (code) => Object.assign(new Error(code), { code });
|
|
13
|
+
|
|
14
|
+
export function defaultDataDir() {
|
|
15
|
+
return path.resolve(process.env.GRAPHLIN_DATA_DIR || path.join(homedir(), '.local', 'state', 'graphlin'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function canonicalProjectRoot(input) {
|
|
19
|
+
if (typeof input !== 'string' || !input || input.length > 4096) throw runtimeError('invalid_project');
|
|
20
|
+
const resolved = await realpath(path.resolve(input));
|
|
21
|
+
if (!(await lstat(resolved)).isDirectory()) throw runtimeError('invalid_project');
|
|
22
|
+
// A .git file identifies a worktree just as a .git directory identifies a checkout.
|
|
23
|
+
// No transcript discovery, repository command, or recursive source scan is needed.
|
|
24
|
+
let current = resolved;
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = await lstat(path.join(current, '.git'));
|
|
28
|
+
if (stat.isFile() || stat.isDirectory()) return current;
|
|
29
|
+
} catch (error) { if (error.code !== 'ENOENT') throw runtimeError('project_unavailable'); }
|
|
30
|
+
const parent = path.dirname(current);
|
|
31
|
+
if (parent === current) return resolved;
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function privateDirectory(directory) {
|
|
37
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
38
|
+
const stat = await lstat(directory);
|
|
39
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (uid() !== undefined && stat.uid !== uid())) {
|
|
40
|
+
throw runtimeError('unsafe_data_directory');
|
|
41
|
+
}
|
|
42
|
+
await chmod(directory, 0o700);
|
|
43
|
+
return realpath(directory);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function projectPaths(projectRoot, dataDir = defaultDataDir(), { create = false } = {}) {
|
|
47
|
+
if (process.platform === 'win32') throw runtimeError('unsupported_platform');
|
|
48
|
+
const root = await canonicalProjectRoot(projectRoot);
|
|
49
|
+
const requested = path.resolve(dataDir);
|
|
50
|
+
// Resolve existing parent aliases, e.g. macOS /var -> /private/var, even before
|
|
51
|
+
// the leaf exists so collector/start agree on the same socket name.
|
|
52
|
+
async function resolveFuture(value) {
|
|
53
|
+
try { return await realpath(value); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT') throw error;
|
|
56
|
+
const parent = path.dirname(value);
|
|
57
|
+
if (parent === value) throw error;
|
|
58
|
+
return path.join(await resolveFuture(parent), path.basename(value));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (create) await privateDirectory(requested);
|
|
62
|
+
const base = await resolveFuture(requested);
|
|
63
|
+
const projectId = hash(root);
|
|
64
|
+
const directory = path.join(base, projectId);
|
|
65
|
+
// Unix socket paths are short even if the project/data path contains spaces
|
|
66
|
+
// or exceeds sockaddr_un's platform limit.
|
|
67
|
+
const sockets = path.join('/tmp', `graphlin-${uid() ?? 'user'}`);
|
|
68
|
+
const socket = path.join(sockets, `${hash(`${base}\0${root}`).slice(0, 36)}.sock`);
|
|
69
|
+
if (create) {
|
|
70
|
+
await privateDirectory(directory);
|
|
71
|
+
await privateDirectory(sockets);
|
|
72
|
+
}
|
|
73
|
+
return { projectRoot: root, projectId, dataDir: base, directory, socket,
|
|
74
|
+
lock: path.join(directory, 'daemon.lock'), state: path.join(directory, 'state.json') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readPrivateJSON(filename, limit = MAX_STATE_BYTES) {
|
|
78
|
+
let file;
|
|
79
|
+
try {
|
|
80
|
+
file = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
81
|
+
const stat = await file.stat();
|
|
82
|
+
if (!stat.isFile() || stat.size > limit || stat.nlink !== 1 ||
|
|
83
|
+
(stat.mode & 0o077) !== 0 || (uid() !== undefined && stat.uid !== uid())) {
|
|
84
|
+
throw runtimeError('unsafe_state_file');
|
|
85
|
+
}
|
|
86
|
+
const bytes = Buffer.alloc(Math.min(limit + 1, stat.size + 1));
|
|
87
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, 0);
|
|
88
|
+
if (bytesRead > limit) throw runtimeError('state_too_large');
|
|
89
|
+
return JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'));
|
|
90
|
+
} finally { await file?.close(); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function atomicJSON(filename, value, limit = MAX_STATE_BYTES) {
|
|
94
|
+
const body = JSON.stringify(value);
|
|
95
|
+
if (Buffer.byteLength(body) > limit) throw runtimeError('state_too_large');
|
|
96
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
97
|
+
let file;
|
|
98
|
+
try {
|
|
99
|
+
file = await open(temporary, 'wx', 0o600);
|
|
100
|
+
await file.writeFile(body);
|
|
101
|
+
await file.sync();
|
|
102
|
+
await file.close(); file = null;
|
|
103
|
+
await rename(temporary, filename);
|
|
104
|
+
} finally {
|
|
105
|
+
await file?.close();
|
|
106
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { atomicJSON, readPrivateJSON, MAX_STATE_BYTES } from './paths.mjs';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function createPersistence(filename, { maxBytes = MAX_STATE_BYTES, now = Date.now } = {}) {
|
|
7
|
+
let pending = null, timer = null, running = null, closed = false, failures = 0;
|
|
8
|
+
function envelope(snapshot) {
|
|
9
|
+
// The caller MUST pass pipeline.getState({ persistent: true }). This module
|
|
10
|
+
// never receives raw host input, candidate content, or an API key.
|
|
11
|
+
const bounded = structuredClone(snapshot);
|
|
12
|
+
const recent = item => Number.isFinite(Date.parse(item?.at)) && Date.parse(item.at) >= now() - RETENTION_MS;
|
|
13
|
+
bounded.history = Array.isArray(bounded.history) ? bounded.history.filter(recent).slice(-64) : [];
|
|
14
|
+
bounded.activity = Array.isArray(bounded.activity) ? bounded.activity.filter(recent).slice(-256) : [];
|
|
15
|
+
if (Array.isArray(bounded.sessionStates)) {
|
|
16
|
+
bounded.sessionStates = bounded.sessionStates.slice(-16).map(session => ({
|
|
17
|
+
...session, history: Array.isArray(session.history) ? session.history.filter(recent).slice(-64) : [],
|
|
18
|
+
activity: Array.isArray(session.activity) ? session.activity.filter(recent).slice(-256) : [],
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
const result = { schemaVersion: 1, savedAt: now(), snapshot: bounded };
|
|
22
|
+
const histories = [bounded, ...(bounded.sessionStates ?? [])];
|
|
23
|
+
while (Buffer.byteLength(JSON.stringify(result)) > maxBytes) {
|
|
24
|
+
const oldest = histories.filter(session => session.history?.length)
|
|
25
|
+
.sort((a, b) => Date.parse(a.history[0].at) - Date.parse(b.history[0].at))[0];
|
|
26
|
+
if (!oldest) break;
|
|
27
|
+
oldest.history.shift();
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
async function drain() {
|
|
32
|
+
if (running) return running;
|
|
33
|
+
running = (async () => {
|
|
34
|
+
while (pending) {
|
|
35
|
+
const value = pending; pending = null;
|
|
36
|
+
try { await atomicJSON(filename, value, maxBytes); } catch { failures++; }
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
try { await running; } finally { running = null; }
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async load() {
|
|
43
|
+
try {
|
|
44
|
+
const value = await readPrivateJSON(filename, maxBytes);
|
|
45
|
+
if (Number.isFinite(value.savedAt) && now() - value.savedAt > RETENTION_MS) {
|
|
46
|
+
await rm(filename, { force: true });
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (value.schemaVersion !== 1 || !Number.isFinite(value.savedAt) ||
|
|
50
|
+
value.savedAt > now() + 60_000 || now() - value.savedAt > RETENTION_MS ||
|
|
51
|
+
value.snapshot?.schemaVersion !== 1) return undefined;
|
|
52
|
+
return value.snapshot;
|
|
53
|
+
} catch { return undefined; }
|
|
54
|
+
},
|
|
55
|
+
schedule(snapshot) {
|
|
56
|
+
if (closed) return;
|
|
57
|
+
try { pending = envelope(snapshot); } catch { failures++; return; }
|
|
58
|
+
if (!timer) timer = setTimeout(() => { timer = null; void drain(); }, 100);
|
|
59
|
+
},
|
|
60
|
+
async flush() { clearTimeout(timer); timer = null; await drain(); },
|
|
61
|
+
async close() { closed = true; clearTimeout(timer); timer = null; await drain(); },
|
|
62
|
+
stats: () => ({ persistenceFailures: failures }),
|
|
63
|
+
};
|
|
64
|
+
}
|