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,1071 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { readdir } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
createPolicy, normalizeHostEvent, metadataEvent, EvidenceStore,
|
|
7
|
+
buildCandidates, emptyGraph, compileDecision, invalidateArtifacts,
|
|
8
|
+
applyPatch, projectGraph,
|
|
9
|
+
} from './core/index.mjs';
|
|
10
|
+
import { safeLabel, safeText, excluded } from './core/privacy.mjs';
|
|
11
|
+
|
|
12
|
+
const MAX_ACTIVITY = 200;
|
|
13
|
+
const MAX_HOOK_EVENTS = 200;
|
|
14
|
+
const MAX_HISTORY = 80;
|
|
15
|
+
const MAX_HISTORY_BYTES = 384 * 1024;
|
|
16
|
+
const MAX_RETENTION_BYTES = 1400 * 1024;
|
|
17
|
+
const MAX_SESSIONS = 16;
|
|
18
|
+
const MAX_DEDUP = 4000;
|
|
19
|
+
const MAX_LOCAL_QUEUE = 64;
|
|
20
|
+
const MAX_CLASSIFICATIONS = 2;
|
|
21
|
+
const MAX_CLASSIFICATION_QUEUE = 64;
|
|
22
|
+
const CLASSIFICATION_QUEUE_TTL_MS = 120_000;
|
|
23
|
+
const DEADLINE_MS = 2000;
|
|
24
|
+
const PENDING_LEASE_MS = 60_000;
|
|
25
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'interrupted', 'unresolved']);
|
|
26
|
+
const SOURCE_EXT = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|rb|php|cs|swift|sql|ya?ml|json|toml|tf)$/i;
|
|
27
|
+
const SKIP_DIR = new Set([
|
|
28
|
+
'.git', '.graphlin', '.graphlin-data', 'node_modules', 'dist', 'build',
|
|
29
|
+
'coverage', '.next', '.cache', '.venv', 'venv', 'vendor', 'research',
|
|
30
|
+
]);
|
|
31
|
+
const FIXED_CLASSIFIER = new Set([
|
|
32
|
+
'ready', 'metadata_only', 'missing_key', 'paused', 'unavailable', 'timeout', 'demo',
|
|
33
|
+
]);
|
|
34
|
+
const opaque = (value) => createHash('sha256').update(value).digest('hex').slice(0, 24);
|
|
35
|
+
|
|
36
|
+
function activityState(event) {
|
|
37
|
+
if (event.kind === 'tool.requested') return 'pending';
|
|
38
|
+
if (event.kind === 'tool.succeeded') return 'succeeded';
|
|
39
|
+
if (event.kind === 'tool.failed' || event.kind === 'tool.denied') return 'failed';
|
|
40
|
+
if (event.kind === 'tool.interrupted') return 'interrupted';
|
|
41
|
+
if (event.kind === 'tool.unresolved') return 'unresolved';
|
|
42
|
+
return 'observed';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function activityLabel(event) {
|
|
46
|
+
const labels = {
|
|
47
|
+
'session.started': 'Session started', 'turn.prompted': 'Request received',
|
|
48
|
+
'intent.observed': 'Public intent observed', 'tool.requested': 'Tool requested',
|
|
49
|
+
'tool.succeeded': 'Tool completed', 'tool.failed': 'Tool failed',
|
|
50
|
+
'tool.denied': 'Tool denied', 'tool.interrupted': 'Tool interrupted',
|
|
51
|
+
'tool.unresolved': 'Tool outcome unavailable', 'batch.completed': 'Tool batch completed',
|
|
52
|
+
'artifact.changed': 'Source changed', 'verification.observed': 'Check result observed',
|
|
53
|
+
'agent.started': 'Agent started', 'agent.stopped': 'Agent stopped',
|
|
54
|
+
'turn.stopped': 'Turn ended', 'session.ended': 'Session ended',
|
|
55
|
+
'capture.gap': 'Observation unavailable',
|
|
56
|
+
};
|
|
57
|
+
return labels[event.kind] ?? 'Activity observed';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function freshSession(id, label) {
|
|
61
|
+
return { id, label, graph: emptyGraph(), activity: [], history: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function restoreGraph(input, { stale = true } = {}) {
|
|
65
|
+
if (!input || input.schemaVersion !== 1 || !Array.isArray(input.nodes) ||
|
|
66
|
+
!Array.isArray(input.edges) || !Number.isSafeInteger(input.revision) ||
|
|
67
|
+
input.revision < 0 || Object.keys(input).sort().join(',') !== 'edges,nodes,revision,schemaVersion') return emptyGraph();
|
|
68
|
+
// Empty graphs have no legal patch operations, but their revision still
|
|
69
|
+
// records real history (for example, removal of the final component).
|
|
70
|
+
if (!input.nodes.length && !input.edges.length) return { ...emptyGraph(), revision: input.revision };
|
|
71
|
+
try {
|
|
72
|
+
// Validate the persisted finite grammar before importing it into live state.
|
|
73
|
+
const graph = structuredClone(applyPatch(emptyGraph(), {
|
|
74
|
+
schemaVersion: 1, id: opaque('restore'), baseRevision: 0, revision: 1, causedBy: [],
|
|
75
|
+
operations: [
|
|
76
|
+
...input.nodes.map(node => ({ op: 'node.upsert', node })),
|
|
77
|
+
...input.edges.map(edge => ({ op: 'edge.upsert', edge })),
|
|
78
|
+
],
|
|
79
|
+
}));
|
|
80
|
+
graph.revision = Number.isSafeInteger(input.revision) && input.revision >= 0 ? input.revision : 0;
|
|
81
|
+
for (const item of stale ? [...graph.nodes, ...graph.edges] : []) {
|
|
82
|
+
item.validity = 'stale';
|
|
83
|
+
item.classification = 'stale';
|
|
84
|
+
if (item.evidenceState === 'verified') item.evidenceState = 'observed';
|
|
85
|
+
if ('activityState' in item) item.activityState = 'unknown';
|
|
86
|
+
}
|
|
87
|
+
if (stale && graph.revision < Number.MAX_SAFE_INTEGER) graph.revision++;
|
|
88
|
+
return graph;
|
|
89
|
+
} catch {
|
|
90
|
+
return emptyGraph();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Integrates independently testable modules. All artifact observations and graph
|
|
96
|
+
* acceptance pass through one local sequence; remote classification runs outside it.
|
|
97
|
+
*/
|
|
98
|
+
export function createPipeline({
|
|
99
|
+
projectRoot, policy: policyOptions, decisionService, onChange = () => {},
|
|
100
|
+
onDiagnostic = () => {}, restoredState, mode = 'live', clock = Date.now,
|
|
101
|
+
classificationDeadlineMs = DEADLINE_MS,
|
|
102
|
+
} = {}) {
|
|
103
|
+
if (!Number.isSafeInteger(classificationDeadlineMs) ||
|
|
104
|
+
classificationDeadlineMs < DEADLINE_MS || classificationDeadlineMs > 10_000) {
|
|
105
|
+
throw new TypeError('INVALID_CLASSIFICATION_DEADLINE');
|
|
106
|
+
}
|
|
107
|
+
const root = realpathSync(projectRoot);
|
|
108
|
+
const inputRoot = path.resolve(projectRoot);
|
|
109
|
+
const projectId = opaque(root);
|
|
110
|
+
const policy = createPolicy(policyOptions ?? {});
|
|
111
|
+
const evidence = new EvidenceStore({ projectRoot, policy });
|
|
112
|
+
const sessions = new Map();
|
|
113
|
+
const dedup = new Map();
|
|
114
|
+
const sessionStarts = new Map();
|
|
115
|
+
const hookEvents = [];
|
|
116
|
+
const knownArtifacts = new Map();
|
|
117
|
+
const messageVersions = new Map();
|
|
118
|
+
const deferredWork = new Map();
|
|
119
|
+
const classificationQueue = [];
|
|
120
|
+
const activeClassifications = new Set();
|
|
121
|
+
const completedClassifications = new Map();
|
|
122
|
+
const tasks = new Set();
|
|
123
|
+
let selectedSession = null;
|
|
124
|
+
let sequence = 0;
|
|
125
|
+
let receipt = 0;
|
|
126
|
+
let paused = false;
|
|
127
|
+
let closed = false;
|
|
128
|
+
let localQueue = 0;
|
|
129
|
+
let dropped = 0;
|
|
130
|
+
let pending = 0;
|
|
131
|
+
let classifier = mode === 'demo' ? 'demo' : policy.transmitSource ? 'ready' : 'metadata_only';
|
|
132
|
+
let serial = Promise.resolve();
|
|
133
|
+
let lastDiscoveryAt = -Infinity;
|
|
134
|
+
let discoveredPaths = [];
|
|
135
|
+
let reconciliationTask = null;
|
|
136
|
+
let resumeScheduled = false;
|
|
137
|
+
|
|
138
|
+
const serialized = (fn) => {
|
|
139
|
+
const operation = serial.then(fn);
|
|
140
|
+
serial = operation.catch(() => {});
|
|
141
|
+
return operation;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function artifactMetadata(artifact) {
|
|
145
|
+
const relative = artifact.relativePath;
|
|
146
|
+
return {
|
|
147
|
+
artifactId: artifact.id, status: artifact.status, complete: artifact.complete === true,
|
|
148
|
+
...(policy.transmitSource && (policy.displayEvidence || policy.persistEvidence) && typeof relative === 'string' &&
|
|
149
|
+
safeText(relative, 4096) && !excluded(relative, policy) ? { path: relative } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function candidateMetadata(candidates) {
|
|
154
|
+
return candidates.map(candidate => ({
|
|
155
|
+
candidateId: candidate.id, artifactId: candidate.artifactId,
|
|
156
|
+
sourceClass: candidate.sourceClass, complete: candidate.complete,
|
|
157
|
+
startLine: candidate.startLine, endLine: candidate.endLine,
|
|
158
|
+
...(policy.transmitSource && (policy.displayEvidence || policy.persistEvidence) && safeLabel(candidate.label)
|
|
159
|
+
? { label: candidate.label } : {}),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function trace(event, stage, detail = {}) {
|
|
164
|
+
try {
|
|
165
|
+
const record = {
|
|
166
|
+
schemaVersion: 1, at: new Date(clock()).toISOString(), stage,
|
|
167
|
+
...(event ? { eventId: event.id, sessionId: event.sessionId,
|
|
168
|
+
eventKind: event.kind, toolCategory: event.toolCategory } : {}),
|
|
169
|
+
...detail,
|
|
170
|
+
};
|
|
171
|
+
// Observers only receive a detached metadata record, never evidence objects.
|
|
172
|
+
const pending = onDiagnostic(structuredClone(record));
|
|
173
|
+
if (pending && typeof pending.then === 'function') Promise.resolve(pending).catch(() => {});
|
|
174
|
+
} catch { /* Logging cannot interrupt capture, classification, or acceptance. */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function classificationContext(candidates, sourceEventId) {
|
|
178
|
+
const ids = new Set(candidates.map(candidate => candidate.artifactId));
|
|
179
|
+
return {
|
|
180
|
+
...(sourceEventId ? { sourceEventId } : {}),
|
|
181
|
+
artifacts: [...ids].flatMap(id => {
|
|
182
|
+
const artifact = knownArtifacts.get(id);
|
|
183
|
+
return artifact ? [{ ...artifact.metadata,
|
|
184
|
+
candidateCount: candidates.filter(candidate => candidate.artifactId === id).length }] : [];
|
|
185
|
+
}),
|
|
186
|
+
candidates: candidateMetadata(candidates),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function observedCandidates(event, artifacts, publicText, sourceEventId) {
|
|
191
|
+
const extraction = [];
|
|
192
|
+
const candidates = buildCandidates({
|
|
193
|
+
event, artifacts, publicText, policy, onDiagnostic: entry => extraction.push(entry),
|
|
194
|
+
});
|
|
195
|
+
trace(event, 'candidates', {
|
|
196
|
+
...classificationContext(candidates, sourceEventId),
|
|
197
|
+
artifacts: artifacts.map(artifact => {
|
|
198
|
+
const entry = extraction.find(item => item.artifactId === artifact.id);
|
|
199
|
+
return { ...artifactMetadata(artifact), candidateCount: entry?.selected ?? 0,
|
|
200
|
+
availableCandidates: entry?.available ?? 0, reason: entry?.reason ?? 'no_candidates' };
|
|
201
|
+
}),
|
|
202
|
+
status: candidates.length ? 'ready' : 'skipped',
|
|
203
|
+
reason: !policy.transmitSource ? 'metadata_only' : candidates.length ? 'candidates_ready' : 'no_candidates',
|
|
204
|
+
diagnostics: { extraction },
|
|
205
|
+
});
|
|
206
|
+
return candidates;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function ensureSession(id) {
|
|
210
|
+
if (sessions.has(id)) return sessions.get(id);
|
|
211
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
212
|
+
// Eviction loses live coverage for an old session; it never merges identities.
|
|
213
|
+
const victim = [...sessions.keys()].find(key => key !== selectedSession);
|
|
214
|
+
if (victim) evictSession(victim);
|
|
215
|
+
dropped++;
|
|
216
|
+
}
|
|
217
|
+
const session = freshSession(id, `Session ${sessions.size + 1}`);
|
|
218
|
+
sessions.set(id, session);
|
|
219
|
+
selectedSession ??= id;
|
|
220
|
+
return session;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function evictSession(id) {
|
|
224
|
+
sessions.delete(id);
|
|
225
|
+
deferredWork.delete(id);
|
|
226
|
+
for (const key of completedClassifications.keys()) {
|
|
227
|
+
if (key.startsWith(`${id}:`)) completedClassifications.delete(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (restoredState?.projectId === projectId) {
|
|
232
|
+
const saved = Array.isArray(restoredState.sessionStates)
|
|
233
|
+
? [restoredState, ...restoredState.sessionStates.filter(s => s.id !== restoredState.sessionId)].slice(-MAX_SESSIONS)
|
|
234
|
+
: [restoredState];
|
|
235
|
+
for (const entry of saved) {
|
|
236
|
+
const id = entry.sessionId ?? entry.id;
|
|
237
|
+
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(id)) continue;
|
|
238
|
+
const session = ensureSession(id);
|
|
239
|
+
session.graph = restoreGraph(entry.graph);
|
|
240
|
+
// Activity is reconstructed through the metadata allowlist, never trusted verbatim.
|
|
241
|
+
session.activity = (Array.isArray(entry.activity) ? entry.activity : [])
|
|
242
|
+
.slice(-MAX_ACTIVITY).map(item => {
|
|
243
|
+
const event = metadataEvent(item);
|
|
244
|
+
return { ...event, label: activityLabel(event), state: activityState(event) };
|
|
245
|
+
});
|
|
246
|
+
session.history = (Array.isArray(entry.history) ? entry.history : []).slice(-MAX_HISTORY)
|
|
247
|
+
.filter(item => Number.isSafeInteger(item.revision) && item.revision >= 0 &&
|
|
248
|
+
item.graph?.revision === item.revision &&
|
|
249
|
+
typeof item.at === 'string' && Number.isFinite(Date.parse(item.at)))
|
|
250
|
+
.map(item => ({ revision: item.revision, at: new Date(item.at).toISOString(),
|
|
251
|
+
graph: restoreGraph(item.graph, { stale: false }) }))
|
|
252
|
+
.filter(item => item.graph.revision === item.revision);
|
|
253
|
+
if (session.graph.revision && session.history.at(-1)?.revision !== session.graph.revision) session.history.push({
|
|
254
|
+
revision: session.graph.revision, at: new Date(clock()).toISOString(),
|
|
255
|
+
graph: structuredClone(session.graph),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (sessions.has(restoredState.sessionId)) selectedSession = restoredState.sessionId;
|
|
259
|
+
trimRetention();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function snapshotSession(session, persistent) {
|
|
263
|
+
return {
|
|
264
|
+
id: session.id, sessionId: session.id, label: session.label,
|
|
265
|
+
graph: projectGraph(session.graph, policy, { persistent }),
|
|
266
|
+
activity: structuredClone(session.activity),
|
|
267
|
+
history: session.history.map(item => ({
|
|
268
|
+
revision: item.revision, at: item.at,
|
|
269
|
+
graph: projectGraph(item.graph, policy, { persistent }),
|
|
270
|
+
})),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getState({ persistent = false } = {}) {
|
|
275
|
+
const current = selectedSession ? sessions.get(selectedSession) : null;
|
|
276
|
+
const view = current ? snapshotSession(current, persistent)
|
|
277
|
+
: { graph: emptyGraph(), activity: [], history: [] };
|
|
278
|
+
const stats = decisionService?.stats?.() ?? {};
|
|
279
|
+
const calls = Number.isSafeInteger(stats.calls) ? stats.calls
|
|
280
|
+
: Number.isSafeInteger(stats.requests) ? stats.requests : 0;
|
|
281
|
+
const state = {
|
|
282
|
+
schemaVersion: 1, projectId, sessionId: selectedSession,
|
|
283
|
+
mode: mode === 'demo' ? 'demo' : 'live', paused,
|
|
284
|
+
sessions: [...sessions.values()].map(session => ({ id: session.id, label: session.label })),
|
|
285
|
+
graph: view.graph, activity: view.activity, history: view.history,
|
|
286
|
+
status: {
|
|
287
|
+
connection: closed ? 'closed' : 'connected',
|
|
288
|
+
classifier: paused ? 'paused' : FIXED_CLASSIFIER.has(classifier) ? classifier : 'unavailable',
|
|
289
|
+
coverage: 'Tools and public prompts; source observations have unknown authorship. Streamed reasoning is not captured.',
|
|
290
|
+
dropped, pending, calls,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
if (persistent) {
|
|
294
|
+
// The selected session is already at the root; avoid duplicating its graph.
|
|
295
|
+
state.sessionStates = [...sessions.values()].filter(session => session.id !== selectedSession)
|
|
296
|
+
.map(session => snapshotSession(session, true));
|
|
297
|
+
} else state.hookEvents = structuredClone(hookEvents);
|
|
298
|
+
return state;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function notify() {
|
|
302
|
+
if (closed) return;
|
|
303
|
+
try { onChange(getState()); } catch { /* Observers cannot break capture. */ }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function recordHook(event) {
|
|
307
|
+
const metadata = metadataEvent(event);
|
|
308
|
+
hookEvents.push({
|
|
309
|
+
...metadata, at: new Date(clock()).toISOString(),
|
|
310
|
+
label: activityLabel(metadata), state: activityState(metadata), receipt: ++receipt,
|
|
311
|
+
});
|
|
312
|
+
if (hookEvents.length > MAX_HOOK_EVENTS) hookEvents.shift();
|
|
313
|
+
// Receipt visibility is independent of queue admission, replay suppression,
|
|
314
|
+
// and the coalesced activity list. Never retain the host payload here.
|
|
315
|
+
notify();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sessionStartIdentity(raw, event, host) {
|
|
319
|
+
// Native hooks do not always supply an event ID. A fixed sequence gives
|
|
320
|
+
// those starts a stable replay identity without hashing raw host content.
|
|
321
|
+
// Without a host ID, identical repeated resumes cannot be distinguished
|
|
322
|
+
// from transport replay and keep the same identity.
|
|
323
|
+
const stable = normalizeHostEvent(raw, { host, projectId, sequence: 0, now: event.at }).event;
|
|
324
|
+
const payload = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
325
|
+
const source = ['startup', 'resume', 'clear', 'compact'].includes(payload?.source) ? payload.source : 'other';
|
|
326
|
+
return { key: `${stable.id}:${source}`, follow: source !== 'compact' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function recordPatch(session, patch) {
|
|
330
|
+
if (!patch || !patch.operations?.length) return;
|
|
331
|
+
session.graph = applyPatch(session.graph, patch);
|
|
332
|
+
session.history.push({
|
|
333
|
+
revision: session.graph.revision, at: new Date(clock()).toISOString(),
|
|
334
|
+
graph: structuredClone(session.graph),
|
|
335
|
+
});
|
|
336
|
+
if (session.history.length > MAX_HISTORY) session.history.shift();
|
|
337
|
+
trimRetention();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function trimRetention() {
|
|
341
|
+
const size = value => Buffer.byteLength(JSON.stringify(value));
|
|
342
|
+
let historyBytes = [...sessions.values()].reduce((sum, session) => sum + size(session.history), 0);
|
|
343
|
+
while (historyBytes > MAX_HISTORY_BYTES) {
|
|
344
|
+
const owner = [...sessions.values()].filter(session => session.history.length)
|
|
345
|
+
.sort((a, b) => a.history[0].at.localeCompare(b.history[0].at))[0];
|
|
346
|
+
if (!owner) break;
|
|
347
|
+
historyBytes -= size(owner.history.shift());
|
|
348
|
+
}
|
|
349
|
+
// Bound retained inactive sessions as well as replay. Eviction is reported
|
|
350
|
+
// as lost coverage and cannot merge an old session into the selected one.
|
|
351
|
+
while (sessions.size > 1 && size([...sessions.values()]) > MAX_RETENTION_BYTES) {
|
|
352
|
+
const victim = [...sessions.keys()].find(id => id !== selectedSession);
|
|
353
|
+
if (!victim) break;
|
|
354
|
+
evictSession(victim);
|
|
355
|
+
dropped++;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function registerArtifacts(artifacts) {
|
|
360
|
+
const changed = [];
|
|
361
|
+
for (const artifact of artifacts) {
|
|
362
|
+
const previous = knownArtifacts.get(artifact.id);
|
|
363
|
+
if (!previous || previous.hash !== artifact.hash ||
|
|
364
|
+
previous.generation !== artifact.generation || previous.status !== artifact.status) {
|
|
365
|
+
changed.push(artifact);
|
|
366
|
+
}
|
|
367
|
+
knownArtifacts.set(artifact.id, {
|
|
368
|
+
hash: artifact.hash, generation: artifact.generation, status: artifact.status,
|
|
369
|
+
path: artifact.path, metadata: artifactMetadata(artifact),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
if (changed.length) {
|
|
373
|
+
for (const session of sessions.values()) {
|
|
374
|
+
const supported = new Set([...session.graph.nodes, ...session.graph.edges]
|
|
375
|
+
.flatMap(item => item.sourceRefs.map(ref => ref.artifactId)));
|
|
376
|
+
if (paused && policy.transmitSource) {
|
|
377
|
+
const ids = changed.filter(artifact => supported.has(artifact.id)).map(artifact => artifact.id);
|
|
378
|
+
if (ids.length) {
|
|
379
|
+
let work = deferredWork.get(session.id);
|
|
380
|
+
if (!work && deferredWork.size < MAX_SESSIONS) {
|
|
381
|
+
work = { artifacts: new Set(), messages: new Map() };
|
|
382
|
+
deferredWork.set(session.id, work);
|
|
383
|
+
}
|
|
384
|
+
if (work) for (const id of ids) {
|
|
385
|
+
if (work.artifacts.size < 128) work.artifacts.add(id);
|
|
386
|
+
else dropped++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
recordPatch(session, invalidateArtifacts(session.graph, changed));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return changed;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function addActivity(session, event) {
|
|
397
|
+
const state = activityState(event);
|
|
398
|
+
const row = { ...metadataEvent(event), label: activityLabel(event), state };
|
|
399
|
+
const existing = event.toolCallId
|
|
400
|
+
? session.activity.findIndex(item => item.toolCallId === event.toolCallId &&
|
|
401
|
+
item.agentId === event.agentId && item.kind?.startsWith('tool.'))
|
|
402
|
+
: -1;
|
|
403
|
+
if (existing >= 0) {
|
|
404
|
+
const old = session.activity[existing];
|
|
405
|
+
if (TERMINAL.has(old.state) && state === 'pending') return;
|
|
406
|
+
session.activity[existing] = row;
|
|
407
|
+
} else {
|
|
408
|
+
session.activity.push(row);
|
|
409
|
+
if (session.activity.length > MAX_ACTIVITY) session.activity.shift();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function expirePending() {
|
|
414
|
+
let changed = false;
|
|
415
|
+
for (const session of sessions.values()) {
|
|
416
|
+
for (let index = 0; index < session.activity.length; index++) {
|
|
417
|
+
const row = session.activity[index];
|
|
418
|
+
if (row.state !== 'pending' || clock() - Date.parse(row.at) < PENDING_LEASE_MS) continue;
|
|
419
|
+
const event = metadataEvent({
|
|
420
|
+
...row, id: opaque(`${row.id}:expired`), kind: 'tool.unresolved',
|
|
421
|
+
outcome: 'unresolved', at: new Date(clock()).toISOString(),
|
|
422
|
+
sequence: ++sequence, incomplete: true,
|
|
423
|
+
});
|
|
424
|
+
session.activity[index] = { ...event, label: activityLabel(event), state: 'unresolved' };
|
|
425
|
+
changed = true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return changed;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function discoverPaths() {
|
|
432
|
+
// Bounded name discovery lets a shell-created file enter the next observation.
|
|
433
|
+
// Core still authorizes each resolved file before reading any content.
|
|
434
|
+
if (clock() - lastDiscoveryAt < 1000) return discoveredPaths;
|
|
435
|
+
lastDiscoveryAt = clock();
|
|
436
|
+
const found = [];
|
|
437
|
+
const dirs = [{ dir: root, depth: 0 }];
|
|
438
|
+
let visited = 0;
|
|
439
|
+
while (dirs.length && found.length < 64 && visited++ < 100) {
|
|
440
|
+
const { dir, depth } = dirs.shift();
|
|
441
|
+
let entries;
|
|
442
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; }
|
|
443
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
if (entry.isSymbolicLink() || entry.name.startsWith('.')) continue;
|
|
446
|
+
if (entry.isDirectory() && depth < 5 && !SKIP_DIR.has(entry.name)) {
|
|
447
|
+
dirs.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
|
448
|
+
} else if (entry.isFile() && SOURCE_EXT.test(entry.name) &&
|
|
449
|
+
!/(?:package-lock|pnpm-lock|yarn\.lock)/.test(entry.name)) {
|
|
450
|
+
found.push(path.join(dir, entry.name));
|
|
451
|
+
if (found.length >= 64) break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
discoveredPaths = found;
|
|
456
|
+
return found;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function canonicalNamedPaths(paths, raw) {
|
|
460
|
+
const aliases = [inputRoot];
|
|
461
|
+
// A host can report a system alias such as /var instead of /private/var.
|
|
462
|
+
// Normalize only an established project-root prefix, never child symlinks.
|
|
463
|
+
if (typeof raw?.cwd === 'string') {
|
|
464
|
+
try { if (realpathSync(raw.cwd) === root) aliases.push(path.resolve(raw.cwd)); } catch {}
|
|
465
|
+
}
|
|
466
|
+
return paths.slice(0, 32).map(file => {
|
|
467
|
+
const absolute = path.resolve(inputRoot, file);
|
|
468
|
+
for (const alias of aliases) {
|
|
469
|
+
const relative = path.relative(alias, absolute);
|
|
470
|
+
if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
471
|
+
return path.resolve(root, relative);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return absolute;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function messageCurrent(candidate) {
|
|
479
|
+
const ref = candidate.sourceRef;
|
|
480
|
+
if (ref?.type !== 'message') return true;
|
|
481
|
+
const current = messageVersions.get(ref.messageId);
|
|
482
|
+
return current?.hash === ref.hash && current?.contentVersion === ref.contentVersion;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function observeMessage(event, text) {
|
|
486
|
+
if (!['intent.observed', 'turn.prompted'].includes(event.kind)) return;
|
|
487
|
+
const ref = {
|
|
488
|
+
type: 'message', messageId: event.id,
|
|
489
|
+
hash: createHash('sha256').update(text ?? '').digest('hex'),
|
|
490
|
+
contentVersion: event.sequence,
|
|
491
|
+
};
|
|
492
|
+
const previous = messageVersions.get(ref.messageId);
|
|
493
|
+
messageVersions.set(ref.messageId, ref);
|
|
494
|
+
if (messageVersions.size > MAX_DEDUP) messageVersions.delete(messageVersions.keys().next().value);
|
|
495
|
+
if (!previous || previous.hash === ref.hash) return;
|
|
496
|
+
for (const session of sessions.values()) {
|
|
497
|
+
const operations = [];
|
|
498
|
+
const removed = new Set();
|
|
499
|
+
for (const [kind, items] of [['node', session.graph.nodes], ['edge', session.graph.edges]]) {
|
|
500
|
+
for (const item of items) {
|
|
501
|
+
const refs = item.sourceRefs.filter(old => old.sourceRef?.messageId !== ref.messageId ||
|
|
502
|
+
old.hash === ref.hash && old.generation === ref.contentVersion);
|
|
503
|
+
if (refs.length === item.sourceRefs.length) continue;
|
|
504
|
+
if (!refs.length) {
|
|
505
|
+
operations.push({ op: `${kind}.remove`, id: item.id });
|
|
506
|
+
if (kind === 'node') removed.add(item.id);
|
|
507
|
+
} else {
|
|
508
|
+
operations.push({ op: `${kind}.upsert`, [kind]: {
|
|
509
|
+
...item, sourceRefs: refs, classification: 'stale', validity: 'stale',
|
|
510
|
+
evidenceState: refs.some(r => r.sourceClass === 'public_intent') ? 'proposed' : 'observed',
|
|
511
|
+
...(kind === 'node' ? { activityState: 'unknown' } : {}),
|
|
512
|
+
} });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const effective = operations.filter(op => op.op !== 'edge.upsert' ||
|
|
517
|
+
!removed.has(op.edge.source) && !removed.has(op.edge.target));
|
|
518
|
+
if (effective.length) recordPatch(session, {
|
|
519
|
+
schemaVersion: 1, id: opaque(`${event.id}:${event.sequence}:${session.id}:${session.graph.revision}`),
|
|
520
|
+
baseRevision: session.graph.revision, revision: session.graph.revision + 1,
|
|
521
|
+
causedBy: [event.id], operations: effective,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function deferClassification(event, candidates) {
|
|
527
|
+
if (!candidates.length || !policy.transmitSource) return;
|
|
528
|
+
let work = deferredWork.get(event.sessionId);
|
|
529
|
+
if (!work) {
|
|
530
|
+
if (deferredWork.size >= MAX_SESSIONS) { dropped++; return; }
|
|
531
|
+
work = { artifacts: new Set(), messages: new Map() };
|
|
532
|
+
deferredWork.set(event.sessionId, work);
|
|
533
|
+
}
|
|
534
|
+
for (const candidate of candidates) {
|
|
535
|
+
if (candidate.sourceRef?.type === 'message') {
|
|
536
|
+
if (!messageCurrent(candidate)) continue;
|
|
537
|
+
if (!work.messages.has(event.id) && work.messages.size >= 16) { dropped++; continue; }
|
|
538
|
+
work.messages.set(event.id, { event, candidates: candidates.filter(c =>
|
|
539
|
+
c.sourceRef?.messageId === candidate.sourceRef.messageId) });
|
|
540
|
+
} else if (work.artifacts.size < 128) work.artifacts.add(candidate.artifactId);
|
|
541
|
+
else dropped++;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function observationEvent(sessionId) {
|
|
546
|
+
return {
|
|
547
|
+
schemaVersion: 1, id: opaque(randomUUID()), projectId, sessionId,
|
|
548
|
+
agentId: opaque(`${projectId}:unattributed`), toolCallId: null,
|
|
549
|
+
kind: 'artifact.changed', toolCategory: 'other', outcome: 'observed',
|
|
550
|
+
at: new Date(clock()).toISOString(), sequence: ++sequence, incomplete: false,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function flushDeferred() {
|
|
555
|
+
if (closed || paused || !deferredWork.size) return;
|
|
556
|
+
const work = [...deferredWork];
|
|
557
|
+
deferredWork.clear();
|
|
558
|
+
const artifacts = await evidence.reconcile();
|
|
559
|
+
registerArtifacts(artifacts);
|
|
560
|
+
for (const [sessionId, entry] of work) {
|
|
561
|
+
const session = sessions.get(sessionId);
|
|
562
|
+
if (!session) continue;
|
|
563
|
+
const byId = new Map(artifacts.map(artifact => [artifact.id, artifact]));
|
|
564
|
+
const covered = new Set();
|
|
565
|
+
const groups = new Map();
|
|
566
|
+
// A relationship may need several files. Reassemble its currently
|
|
567
|
+
// authorized dependencies instead of classifying each endpoint alone.
|
|
568
|
+
for (const edge of session.graph.edges) {
|
|
569
|
+
const ids = [...new Set(edge.sourceRefs.filter(ref => ref.sourceClass === 'source')
|
|
570
|
+
.map(ref => ref.artifactId))].sort();
|
|
571
|
+
if (!ids.some(id => entry.artifacts.has(id))) continue;
|
|
572
|
+
if (groups.size >= 16) { dropped++; continue; }
|
|
573
|
+
groups.set(ids.join(','), ids.flatMap(id => byId.has(id) ? [byId.get(id)] : []));
|
|
574
|
+
ids.forEach(id => covered.add(id));
|
|
575
|
+
}
|
|
576
|
+
const remaining = artifacts.filter(a => entry.artifacts.has(a.id) && !covered.has(a.id));
|
|
577
|
+
for (let index = 0; index < remaining.length; index += 4) {
|
|
578
|
+
if (groups.size >= 16) { dropped += remaining.length - index; break; }
|
|
579
|
+
const group = remaining.slice(index, index + 4);
|
|
580
|
+
groups.set(group.map(a => a.id).join(','), group);
|
|
581
|
+
}
|
|
582
|
+
for (const group of groups.values()) {
|
|
583
|
+
const event = observationEvent(sessionId);
|
|
584
|
+
const candidates = observedCandidates(event, group, null);
|
|
585
|
+
scheduleClassification(event, candidates);
|
|
586
|
+
}
|
|
587
|
+
for (const message of entry.messages.values()) {
|
|
588
|
+
scheduleClassification(message.event, message.candidates.filter(messageCurrent));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
notify();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function sourceVersions(candidates) {
|
|
595
|
+
return [...new Map(candidates.filter(candidate => candidate.sourceRef?.type !== 'message')
|
|
596
|
+
.map(candidate => [candidate.artifactId, {
|
|
597
|
+
artifactId: candidate.artifactId, hash: candidate.hash, generation: candidate.generation,
|
|
598
|
+
}])).values()];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const versionKey = ref => `${ref.hash}:${ref.generation}`;
|
|
602
|
+
const sourceIdentity = job => job.candidates.some(candidate => candidate.sourceRef?.type === 'message')
|
|
603
|
+
? null : sourceVersions(job.candidates).map(ref => ref.artifactId).sort().join(',');
|
|
604
|
+
const classificationKey = job => `${job.event.sessionId}:${opaque(JSON.stringify([
|
|
605
|
+
policy.version,
|
|
606
|
+
{ kind: job.event.kind, toolCategory: job.event.toolCategory,
|
|
607
|
+
outcome: job.event.outcome, incomplete: job.event.incomplete !== false },
|
|
608
|
+
job.candidates.map(candidate => candidate.digest),
|
|
609
|
+
]))}`;
|
|
610
|
+
|
|
611
|
+
function coverageReason(job) {
|
|
612
|
+
if (!sourceIdentity(job)) return null;
|
|
613
|
+
const key = classificationKey(job);
|
|
614
|
+
// Only the exact ordered candidate input has been examined. A union of
|
|
615
|
+
// individual file judgments does not cover a cross-file relationship, and
|
|
616
|
+
// a small shared budget does not cover a later fuller Read. Order also
|
|
617
|
+
// affects which finite relation proposals are considered. Include the event
|
|
618
|
+
// metadata given to Jev: complete capture can promote an earlier tentative
|
|
619
|
+
// judgment even when its candidate evidence has not changed.
|
|
620
|
+
if (completedClassifications.has(key)) return 'source_version_completed';
|
|
621
|
+
if ([...activeClassifications, ...classificationQueue].some(other =>
|
|
622
|
+
other !== job && other.session === job.session && classificationKey(other) === key)) {
|
|
623
|
+
return 'source_version_pending';
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function queueDiagnostics(job, extra = {}) {
|
|
629
|
+
return { queue: {
|
|
630
|
+
waitMs: Math.max(0, clock() - job.enqueuedAt),
|
|
631
|
+
depth: classificationQueue.length, active: activeClassifications.size,
|
|
632
|
+
capacity: MAX_CLASSIFICATION_QUEUE, ...extra,
|
|
633
|
+
} };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function skipJob(job, reason, status = 'skipped') {
|
|
637
|
+
trace(job.event, 'skip', {
|
|
638
|
+
...classificationContext(job.candidates, job.sourceEventId), status, reason,
|
|
639
|
+
diagnostics: queueDiagnostics(job),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function scheduleClassification(event, candidates, sourceEventId) {
|
|
644
|
+
const context = classificationContext(candidates, sourceEventId);
|
|
645
|
+
const skip = reason => trace(event, 'skip', { ...context, status: 'skipped', reason });
|
|
646
|
+
if (closed) { skip('pipeline_closed'); return; }
|
|
647
|
+
if (!policy.transmitSource) { skip('metadata_only'); return; }
|
|
648
|
+
if (!candidates.length) { skip('no_candidates'); return; }
|
|
649
|
+
if (!decisionService) { skip('classifier_unavailable'); return; }
|
|
650
|
+
const session = sessions.get(event.sessionId);
|
|
651
|
+
if (!session) { skip('session_evicted'); return; }
|
|
652
|
+
if (paused) { deferClassification(event, candidates); skip('paused_deferred'); return; }
|
|
653
|
+
const job = { event, candidates, sourceEventId, session, enqueuedAt: clock(),
|
|
654
|
+
needsRefresh: activeClassifications.size >= MAX_CLASSIFICATIONS };
|
|
655
|
+
const covered = coverageReason(job);
|
|
656
|
+
if (covered) { skipJob(job, covered); return; }
|
|
657
|
+
const identity = sourceIdentity(job);
|
|
658
|
+
const superseded = identity && classificationQueue.findIndex(other =>
|
|
659
|
+
other.session === session && sourceIdentity(other) === identity &&
|
|
660
|
+
sourceVersions(other.candidates).some(ref => !sourceVersions(candidates).some(current =>
|
|
661
|
+
current.artifactId === ref.artifactId && versionKey(current) === versionKey(ref))));
|
|
662
|
+
if (Number.isInteger(superseded) && superseded >= 0) {
|
|
663
|
+
skipJob(classificationQueue[superseded], 'queued_source_superseded');
|
|
664
|
+
job.needsRefresh = true;
|
|
665
|
+
classificationQueue[superseded] = job;
|
|
666
|
+
} else {
|
|
667
|
+
if (classificationQueue.length >= MAX_CLASSIFICATION_QUEUE) {
|
|
668
|
+
dropped++;
|
|
669
|
+
skipJob(job, 'classification_queue_full');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
pending++;
|
|
673
|
+
classificationQueue.push(job);
|
|
674
|
+
}
|
|
675
|
+
trace(event, 'classification', { ...context, status: 'queued', reason: 'classification_queued',
|
|
676
|
+
diagnostics: queueDiagnostics(job) });
|
|
677
|
+
pumpClassifications();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function pumpClassifications() {
|
|
681
|
+
if (closed || paused) return;
|
|
682
|
+
while (classificationQueue.length && activeClassifications.size < MAX_CLASSIFICATIONS) {
|
|
683
|
+
const job = classificationQueue.shift();
|
|
684
|
+
activeClassifications.add(job);
|
|
685
|
+
job.controller = new AbortController();
|
|
686
|
+
const task = runClassification(job).catch(() => {
|
|
687
|
+
// Fixed diagnostics only: neither input nor API error bodies enter the feed.
|
|
688
|
+
dropped++;
|
|
689
|
+
classifier = 'unavailable';
|
|
690
|
+
skipJob(job, 'classification_pipeline_error', 'failed');
|
|
691
|
+
}).finally(() => {
|
|
692
|
+
pending--;
|
|
693
|
+
activeClassifications.delete(job);
|
|
694
|
+
tasks.delete(task);
|
|
695
|
+
pumpClassifications();
|
|
696
|
+
notify();
|
|
697
|
+
});
|
|
698
|
+
tasks.add(task);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function refreshQueuedJob(job) {
|
|
703
|
+
if (closed || sessions.get(job.event.sessionId) !== job.session) {
|
|
704
|
+
skipJob(job, closed ? 'pipeline_closed' : 'session_evicted');
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
if (paused) { deferClassification(job.event, job.candidates); skipJob(job, 'paused_deferred'); return false; }
|
|
708
|
+
if (clock() - job.enqueuedAt >= CLASSIFICATION_QUEUE_TTL_MS) {
|
|
709
|
+
dropped++;
|
|
710
|
+
skipJob(job, 'classification_queue_expired');
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
const before = sourceVersions(job.candidates);
|
|
714
|
+
if (before.length) {
|
|
715
|
+
// Queued snippets may no longer describe the worktree. Reauthorize and
|
|
716
|
+
// reread the entire source before rebuilding any candidate from it.
|
|
717
|
+
const artifacts = await evidence.capture(before.flatMap(ref => {
|
|
718
|
+
const file = knownArtifacts.get(ref.artifactId)?.path;
|
|
719
|
+
return file ? [file] : [];
|
|
720
|
+
}));
|
|
721
|
+
registerArtifacts(artifacts);
|
|
722
|
+
const messages = job.candidates.filter(candidate =>
|
|
723
|
+
candidate.sourceRef?.type === 'message' && messageCurrent(candidate));
|
|
724
|
+
job.candidates = [...observedCandidates(job.event, artifacts, null, job.sourceEventId), ...messages].slice(0, 12);
|
|
725
|
+
if (!job.candidates.length) { skipJob(job, 'queued_source_unavailable'); return false; }
|
|
726
|
+
if (before.some(ref => !sourceVersions(job.candidates).some(current =>
|
|
727
|
+
current.artifactId === ref.artifactId && versionKey(current) === versionKey(ref)))) {
|
|
728
|
+
trace(job.event, 'classification', {
|
|
729
|
+
...classificationContext(job.candidates, job.sourceEventId),
|
|
730
|
+
status: 'queued', reason: 'queued_source_refreshed', diagnostics: queueDiagnostics(job),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
job.candidates = job.candidates.filter(messageCurrent);
|
|
735
|
+
if (!job.candidates.length) { skipJob(job, 'source_changed_during_classification'); return false; }
|
|
736
|
+
}
|
|
737
|
+
const covered = coverageReason(job);
|
|
738
|
+
if (covered) { skipJob(job, covered); return false; }
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function runClassification(job) {
|
|
743
|
+
if (job.needsRefresh && !await serialized(() => refreshQueuedJob(job))) return;
|
|
744
|
+
const { event, candidates, sourceEventId, session } = job;
|
|
745
|
+
const context = classificationContext(candidates, sourceEventId);
|
|
746
|
+
const skip = reason => skipJob(job, reason);
|
|
747
|
+
if (closed || job.controller.signal.aborted) { skip('pipeline_closed'); return; }
|
|
748
|
+
if (sessions.get(event.sessionId) !== session) { skip('session_evicted'); return; }
|
|
749
|
+
if (paused) { deferClassification(event, candidates); skip('paused_deferred'); return; }
|
|
750
|
+
if (clock() - job.enqueuedAt >= CLASSIFICATION_QUEUE_TTL_MS) {
|
|
751
|
+
dropped++;
|
|
752
|
+
skip('classification_queue_expired');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
// Waiting for a free workflow does not spend Jev's configured active
|
|
756
|
+
// deadline. The slot remains occupied until final local acceptance ends.
|
|
757
|
+
const deadlineAt = clock() + classificationDeadlineMs;
|
|
758
|
+
trace(event, 'classification', { ...context, status: 'started', reason: 'classification_started',
|
|
759
|
+
diagnostics: queueDiagnostics(job) });
|
|
760
|
+
let timer;
|
|
761
|
+
let cancel;
|
|
762
|
+
try {
|
|
763
|
+
let result;
|
|
764
|
+
try {
|
|
765
|
+
const interrupted = new Promise(resolve => {
|
|
766
|
+
cancel = () => resolve({ status: 'unavailable', diagnostics: { code: 'service_closed' } });
|
|
767
|
+
job.controller.signal.addEventListener('abort', cancel, { once: true });
|
|
768
|
+
timer = setTimeout(() => {
|
|
769
|
+
resolve({ status: 'timeout', diagnostics: { code: 'deadline_exceeded' } });
|
|
770
|
+
job.controller.abort();
|
|
771
|
+
}, classificationDeadlineMs);
|
|
772
|
+
});
|
|
773
|
+
result = await Promise.race([
|
|
774
|
+
decisionService.classify({ event, candidates, policy, deadlineAt, signal: job.controller.signal }),
|
|
775
|
+
interrupted,
|
|
776
|
+
]);
|
|
777
|
+
} catch {
|
|
778
|
+
result = { status: 'unavailable', diagnostics: { code: 'classifier_exception' } };
|
|
779
|
+
}
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
trace(event, 'classification', { ...context, status: result?.status ?? 'invalid',
|
|
782
|
+
reason: result?.diagnostics?.code ?? result?.status ?? 'invalid_result',
|
|
783
|
+
diagnostics: result?.diagnostics ?? {} });
|
|
784
|
+
await serialized(async () => {
|
|
785
|
+
if (closed || !sessions.has(event.sessionId)) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
|
|
786
|
+
if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
|
|
787
|
+
if (clock() >= deadlineAt) { classifier = 'timeout'; dropped++; skip('deadline_before_apply'); notify(); return; }
|
|
788
|
+
if (result.status === 'timeout') classifier = 'timeout';
|
|
789
|
+
else if (result.status === 'unavailable') {
|
|
790
|
+
classifier = result.diagnostics?.code === 'missing_key' ? 'missing_key' : 'unavailable';
|
|
791
|
+
} else if (result.status === 'invalid' || result.status === 'overloaded') {
|
|
792
|
+
classifier = 'unavailable';
|
|
793
|
+
} else classifier = mode === 'demo' ? 'demo' : 'ready';
|
|
794
|
+
const bundle = result.bundle;
|
|
795
|
+
if (['accepted', 'abstained', 'irrelevant'].includes(result.status) &&
|
|
796
|
+
bundle?.policyVersion === policy.version && Array.isArray(bundle.candidates)) {
|
|
797
|
+
// Reobserve the worktree before accepting remote answers; a tool could
|
|
798
|
+
// have edited these files while either Jev request was in flight.
|
|
799
|
+
registerArtifacts(await evidence.reconcile());
|
|
800
|
+
if (closed || sessions.get(event.sessionId) !== session) { skip(closed ? 'pipeline_closed' : 'session_evicted'); return; }
|
|
801
|
+
if (paused) { deferClassification(event, candidates); skip('paused_deferred'); notify(); return; }
|
|
802
|
+
const artifactRefs = sourceVersions(candidates);
|
|
803
|
+
if (clock() >= deadlineAt) {
|
|
804
|
+
classifier = 'timeout';
|
|
805
|
+
dropped++;
|
|
806
|
+
skip('deadline_after_revalidation');
|
|
807
|
+
} else if (evidence.isCurrent(artifactRefs) && candidates.every(messageCurrent)) {
|
|
808
|
+
// Remember only completed judgments over still-current source.
|
|
809
|
+
// Timeouts, unavailable results, and stale answers remain retryable.
|
|
810
|
+
const remember = () => {
|
|
811
|
+
if (!artifactRefs.length) return;
|
|
812
|
+
completedClassifications.set(classificationKey(job), true);
|
|
813
|
+
if (completedClassifications.size > MAX_DEDUP) {
|
|
814
|
+
completedClassifications.delete(completedClassifications.keys().next().value);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
if (result.status === 'irrelevant') {
|
|
818
|
+
remember();
|
|
819
|
+
skip('classification_not_drawable');
|
|
820
|
+
notify();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const before = session.graph;
|
|
824
|
+
const admission = [];
|
|
825
|
+
const patch = compileDecision(before, { event, decision: result, policy,
|
|
826
|
+
onDiagnostic: entry => admission.push(entry) });
|
|
827
|
+
recordPatch(session, patch);
|
|
828
|
+
remember();
|
|
829
|
+
const after = session.graph;
|
|
830
|
+
const counts = { revisionBefore: before.revision, revisionAfter: after.revision };
|
|
831
|
+
for (const kind of ['nodes', 'edges']) {
|
|
832
|
+
const previous = new Map(before[kind].map(item => [item.id, item]));
|
|
833
|
+
const current = new Map(after[kind].map(item => [item.id, item]));
|
|
834
|
+
counts[`${kind}Added`] = [...current.keys()].filter(id => !previous.has(id)).length;
|
|
835
|
+
counts[`${kind}Removed`] = [...previous.keys()].filter(id => !current.has(id)).length;
|
|
836
|
+
counts[`${kind}Updated`] = [...current].filter(([id, item]) =>
|
|
837
|
+
previous.has(id) && JSON.stringify(previous.get(id)) !== JSON.stringify(item)).length;
|
|
838
|
+
}
|
|
839
|
+
trace(event, 'apply', { ...context, status: patch ? 'applied' : 'unchanged',
|
|
840
|
+
reason: patch ? 'patch_applied' : 'no_graph_change', patch: counts, diagnostics: { admission } });
|
|
841
|
+
} else {
|
|
842
|
+
dropped++;
|
|
843
|
+
skip('source_changed_during_classification');
|
|
844
|
+
}
|
|
845
|
+
} else skip(['accepted', 'abstained'].includes(result.status) ? 'invalid_bundle' : 'classification_not_drawable');
|
|
846
|
+
notify();
|
|
847
|
+
});
|
|
848
|
+
} finally {
|
|
849
|
+
clearTimeout(timer);
|
|
850
|
+
job.controller.signal.removeEventListener('abort', cancel);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function scheduleSourceGroup(event, artifacts, sourceEventId) {
|
|
855
|
+
const observation = {
|
|
856
|
+
...event, id: opaque(`${event.id}:snapshot:${artifacts.map(a => `${a.id}:${a.generation}`).join(',')}`),
|
|
857
|
+
kind: 'artifact.changed', agentId: opaque(`${projectId}:unattributed`),
|
|
858
|
+
toolCallId: null, toolCategory: 'other', outcome: 'observed',
|
|
859
|
+
};
|
|
860
|
+
scheduleClassification(observation, observedCandidates(observation, artifacts, null, sourceEventId), sourceEventId);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function ingest(raw, { host = 'claude' } = {}) {
|
|
864
|
+
if (closed) { trace(null, 'skip', { status: 'skipped', reason: 'pipeline_closed' }); return { accepted: false, reason: 'closed' }; }
|
|
865
|
+
let prepared;
|
|
866
|
+
try {
|
|
867
|
+
prepared = normalizeHostEvent(raw, {
|
|
868
|
+
host, projectId, sequence: ++sequence, now: new Date(clock()).toISOString(),
|
|
869
|
+
});
|
|
870
|
+
} catch {
|
|
871
|
+
recordHook({
|
|
872
|
+
projectId, id: opaque(randomUUID()), kind: 'capture.gap',
|
|
873
|
+
sequence, at: new Date(clock()).toISOString(),
|
|
874
|
+
});
|
|
875
|
+
dropped++;
|
|
876
|
+
trace(null, 'skip', { status: 'failed', reason: 'invalid_capture' });
|
|
877
|
+
notify();
|
|
878
|
+
return { accepted: false, reason: 'invalid' };
|
|
879
|
+
}
|
|
880
|
+
recordHook(prepared.event);
|
|
881
|
+
if (localQueue >= MAX_LOCAL_QUEUE) {
|
|
882
|
+
dropped++;
|
|
883
|
+
trace(null, 'skip', { status: 'skipped', reason: 'capture_queue_full' });
|
|
884
|
+
notify();
|
|
885
|
+
return { accepted: false, reason: 'overloaded' };
|
|
886
|
+
}
|
|
887
|
+
localQueue++;
|
|
888
|
+
try {
|
|
889
|
+
return await serialized(async () => {
|
|
890
|
+
if (closed) return { accepted: false, reason: 'closed' };
|
|
891
|
+
const event = prepared.event;
|
|
892
|
+
if (!event?.sessionId || !event.id) { dropped++; return { accepted: false, reason: 'invalid' }; }
|
|
893
|
+
let followSession = false;
|
|
894
|
+
const isMessage = ['intent.observed', 'turn.prompted'].includes(event.kind);
|
|
895
|
+
const key = event.toolCallId && event.kind.startsWith('tool.')
|
|
896
|
+
? `${event.sessionId}:${event.agentId}:${event.toolCallId}:${event.kind}`
|
|
897
|
+
: event.id;
|
|
898
|
+
if (isMessage) {
|
|
899
|
+
const hash = createHash('sha256').update(prepared.publicText ?? '').digest('hex');
|
|
900
|
+
if (messageVersions.get(event.id)?.hash === hash) {
|
|
901
|
+
trace(event, 'skip', { status: 'skipped', reason: 'duplicate_event' });
|
|
902
|
+
return { accepted: true, duplicate: true };
|
|
903
|
+
}
|
|
904
|
+
} else if (event.kind === 'session.started') {
|
|
905
|
+
const start = sessionStartIdentity(raw, event, host);
|
|
906
|
+
if (sessionStarts.has(start.key)) {
|
|
907
|
+
trace(event, 'skip', { status: 'skipped', reason: 'duplicate_event' });
|
|
908
|
+
return { accepted: true, duplicate: true };
|
|
909
|
+
}
|
|
910
|
+
// Keep lifecycle replay identities apart from ordinary tool traffic,
|
|
911
|
+
// including when the corresponding old session has been evicted.
|
|
912
|
+
sessionStarts.set(start.key, true);
|
|
913
|
+
if (sessionStarts.size > MAX_DEDUP) sessionStarts.delete(sessionStarts.keys().next().value);
|
|
914
|
+
// Compaction can run in another active session. Its lifecycle receipt
|
|
915
|
+
// remains visible without overriding the user's current session.
|
|
916
|
+
followSession = start.follow;
|
|
917
|
+
} else {
|
|
918
|
+
if (dedup.has(key)) {
|
|
919
|
+
trace(event, 'skip', { status: 'skipped', reason: 'duplicate_event' });
|
|
920
|
+
return { accepted: true, duplicate: true };
|
|
921
|
+
}
|
|
922
|
+
dedup.set(key, true);
|
|
923
|
+
if (dedup.size > MAX_DEDUP) dedup.delete(dedup.keys().next().value);
|
|
924
|
+
}
|
|
925
|
+
const session = ensureSession(event.sessionId);
|
|
926
|
+
if (followSession) selectedSession = session.id;
|
|
927
|
+
observeMessage(event, prepared.publicText);
|
|
928
|
+
addActivity(session, event);
|
|
929
|
+
if (event.kind === 'capture.gap') dropped++;
|
|
930
|
+
// Publish the new selection before source discovery or classification.
|
|
931
|
+
if (event.kind === 'session.started') notify();
|
|
932
|
+
let artifacts = [];
|
|
933
|
+
let namedSet = new Set();
|
|
934
|
+
try {
|
|
935
|
+
const named = Array.isArray(prepared.paths) ? canonicalNamedPaths(prepared.paths, raw) : [];
|
|
936
|
+
const discover = event.kind === 'session.started' ||
|
|
937
|
+
['tool.succeeded', 'tool.failed'].includes(event.kind);
|
|
938
|
+
const paths = [...new Set([...named, ...(discover ? await discoverPaths() : [])])];
|
|
939
|
+
// EvidenceStore bounds each capture to 32 paths. Process the bounded
|
|
940
|
+
// discovery list in chunks rather than silently losing its tail.
|
|
941
|
+
for (let index = 0; index < paths.length; index += 32) {
|
|
942
|
+
artifacts.push(...await evidence.capture(paths.slice(index, index + 32)));
|
|
943
|
+
}
|
|
944
|
+
const changed = registerArtifacts(artifacts);
|
|
945
|
+
trace(event, 'capture', { status: 'observed', reason: 'artifacts_observed',
|
|
946
|
+
artifacts: artifacts.map(artifact => ({ ...artifactMetadata(artifact),
|
|
947
|
+
reason: changed.some(item => item.id === artifact.id) ? 'artifact_changed' : 'artifact_unchanged' })) });
|
|
948
|
+
// A tool completion also reconciles prior support, including deletions
|
|
949
|
+
// omitted from the tool's returned file list.
|
|
950
|
+
if (event.kind.startsWith('tool.') && event.kind !== 'tool.requested') {
|
|
951
|
+
registerArtifacts(await evidence.reconcile());
|
|
952
|
+
}
|
|
953
|
+
namedSet = new Set(named.map(file => path.resolve(root, file)));
|
|
954
|
+
// Global observation history is not a session's classification
|
|
955
|
+
// history. A new session may discover entirely unchanged source.
|
|
956
|
+
artifacts = artifacts.filter(a => discover || changed.some(c => c.id === a.id) || namedSet.has(a.path));
|
|
957
|
+
} catch {
|
|
958
|
+
dropped++;
|
|
959
|
+
trace(event, 'skip', { status: 'failed', reason: 'capture_failed' });
|
|
960
|
+
}
|
|
961
|
+
notify();
|
|
962
|
+
// Requests describe intentions. They cannot confirm future file content.
|
|
963
|
+
if (event.kind !== 'tool.requested' && event.kind !== 'capture.gap') {
|
|
964
|
+
if (artifacts.length && !prepared.publicText) {
|
|
965
|
+
// Preserve explicit multi-file context in small groups. Incidental
|
|
966
|
+
// discovery gets its own per-file budget, so a busy source file
|
|
967
|
+
// cannot crowd the rest of an existing project out of the diagram.
|
|
968
|
+
const named = artifacts.filter(artifact => namedSet.has(artifact.path));
|
|
969
|
+
for (let index = 0; index < named.length; index += 4) {
|
|
970
|
+
scheduleSourceGroup(event, named.slice(index, index + 4), event.id);
|
|
971
|
+
}
|
|
972
|
+
for (const artifact of artifacts.filter(artifact => !namedSet.has(artifact.path))) {
|
|
973
|
+
scheduleSourceGroup(event, [artifact], event.id);
|
|
974
|
+
}
|
|
975
|
+
} else {
|
|
976
|
+
const candidates = observedCandidates(event, artifacts, prepared.publicText, event.id);
|
|
977
|
+
scheduleClassification(event, candidates, event.id);
|
|
978
|
+
}
|
|
979
|
+
notify();
|
|
980
|
+
} else trace(event, 'skip', { status: 'skipped',
|
|
981
|
+
reason: event.kind === 'tool.requested' ? 'tool_request_has_no_source_outcome' : 'unsupported_event' });
|
|
982
|
+
return { accepted: true, eventId: event.id };
|
|
983
|
+
});
|
|
984
|
+
} catch {
|
|
985
|
+
dropped++;
|
|
986
|
+
trace(null, 'skip', { status: 'failed', reason: 'invalid_capture' });
|
|
987
|
+
notify();
|
|
988
|
+
return { accepted: false, reason: 'invalid' };
|
|
989
|
+
} finally {
|
|
990
|
+
localQueue--;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function reconcile() {
|
|
995
|
+
if (reconciliationTask) return reconciliationTask;
|
|
996
|
+
if (closed || localQueue >= MAX_LOCAL_QUEUE) return Promise.resolve();
|
|
997
|
+
localQueue++;
|
|
998
|
+
reconciliationTask = serialized(async () => {
|
|
999
|
+
if (closed) return;
|
|
1000
|
+
const expired = expirePending();
|
|
1001
|
+
const artifacts = await evidence.capture(await discoverPaths());
|
|
1002
|
+
const known = await evidence.reconcile();
|
|
1003
|
+
const changed = registerArtifacts([...artifacts, ...known]);
|
|
1004
|
+
if (!changed.length) { if (expired) notify(); return; }
|
|
1005
|
+
const session = selectedSession ? sessions.get(selectedSession) : null;
|
|
1006
|
+
if (session) {
|
|
1007
|
+
const event = observationEvent(session.id);
|
|
1008
|
+
addActivity(session, event);
|
|
1009
|
+
trace(event, 'capture', { status: 'observed', reason: 'source_reconciliation',
|
|
1010
|
+
artifacts: changed.map(artifactMetadata) });
|
|
1011
|
+
for (let index = 0; index < changed.length; index += 4) {
|
|
1012
|
+
scheduleSourceGroup(event, changed.slice(index, index + 4), event.id);
|
|
1013
|
+
}
|
|
1014
|
+
} else trace(null, 'skip', { status: 'skipped', reason: 'no_session',
|
|
1015
|
+
artifacts: changed.map(artifactMetadata) });
|
|
1016
|
+
notify();
|
|
1017
|
+
}).finally(() => { localQueue--; reconciliationTask = null; });
|
|
1018
|
+
return reconciliationTask;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function setPaused(value) {
|
|
1022
|
+
const wasPaused = paused;
|
|
1023
|
+
paused = Boolean(value);
|
|
1024
|
+
if (!wasPaused && paused) {
|
|
1025
|
+
for (const job of classificationQueue.splice(0)) {
|
|
1026
|
+
deferClassification(job.event, job.candidates);
|
|
1027
|
+
skipJob(job, 'paused_deferred');
|
|
1028
|
+
pending--;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (wasPaused && !paused && !resumeScheduled) {
|
|
1032
|
+
resumeScheduled = true;
|
|
1033
|
+
serialized(flushDeferred).catch(() => { dropped++; })
|
|
1034
|
+
.finally(() => { resumeScheduled = false; });
|
|
1035
|
+
}
|
|
1036
|
+
notify();
|
|
1037
|
+
return getState();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function selectSession(id) {
|
|
1041
|
+
if (!sessions.has(id)) return false;
|
|
1042
|
+
selectedSession = id;
|
|
1043
|
+
notify();
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function whenIdle() {
|
|
1048
|
+
await serial;
|
|
1049
|
+
while (tasks.size || classificationQueue.length) {
|
|
1050
|
+
pumpClassifications();
|
|
1051
|
+
await Promise.allSettled([...tasks]);
|
|
1052
|
+
await serial;
|
|
1053
|
+
}
|
|
1054
|
+
await serial;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async function close() {
|
|
1058
|
+
if (closed) return;
|
|
1059
|
+
closed = true;
|
|
1060
|
+
deferredWork.clear();
|
|
1061
|
+
for (const job of classificationQueue.splice(0)) {
|
|
1062
|
+
skipJob(job, 'pipeline_closed');
|
|
1063
|
+
pending--;
|
|
1064
|
+
}
|
|
1065
|
+
for (const job of activeClassifications) job.controller.abort();
|
|
1066
|
+
decisionService?.close?.();
|
|
1067
|
+
await whenIdle();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return { ingest, getState, reconcile, setPaused, selectSession, whenIdle, close };
|
|
1071
|
+
}
|