@spexcode/spec-cli 0.6.7 → 0.7.0-next.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/bin/spex.mjs +175 -21
- package/dist/cli.js +121 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/file-write.js +14 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +29 -18
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +24 -6
- package/dist/harness.js +510 -72
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +98 -1
- package/dist/index.js +294 -36
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.d.ts +2 -1
- package/dist/machine-peer.js +46 -15
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/opencode.js +10 -2
- package/dist/pi-harness.js +18 -5
- package/dist/pi-headless.d.ts +2 -0
- package/dist/pi-headless.js +27 -0
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-transcript.d.ts +57 -0
- package/dist/session-transcript.js +137 -0
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +112 -15
- package/dist/sessions.js +1553 -764
- package/dist/shim-runtime.js +14 -0
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +8 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/dist/execution-trace.d.ts +0 -26
- package/dist/execution-trace.js +0 -475
- package/dist/session-execution.d.ts +0 -10
- package/dist/session-execution.js +0 -70
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/dist/client.js
CHANGED
|
@@ -5,7 +5,8 @@ import { repoRoot } from '@spexcode/spec-core';
|
|
|
5
5
|
import { resourceBudgets } from './host-resources.js';
|
|
6
6
|
import { envSessionId, listSessionIds, readPublicRecordEntry } from '@spexcode/spec-core';
|
|
7
7
|
import { cockpitReview } from './cockpit.js';
|
|
8
|
-
import {
|
|
8
|
+
import { configuredSessionApplicationIfCutover, sessionApplicationCutoverState } from './session-application.js';
|
|
9
|
+
import { apiBaseInfo, assertProjectMatch, displayStatusForProposal, fromRaw, optionArgv, resolveSession, toSession } from './sessions.js';
|
|
9
10
|
import { resolveMachinePeer } from './machine-peer.js';
|
|
10
11
|
export class BackendError extends Error {
|
|
11
12
|
status;
|
|
@@ -136,7 +137,7 @@ function cachedStatus(rec) {
|
|
|
136
137
|
if (rec.archived)
|
|
137
138
|
return 'offline';
|
|
138
139
|
if (rec.status === 'awaiting')
|
|
139
|
-
return rec.proposal
|
|
140
|
+
return displayStatusForProposal(rec.proposal);
|
|
140
141
|
return rec.status === 'active' || rec.status === 'idle' ? 'unknown' : rec.status;
|
|
141
142
|
}
|
|
142
143
|
function corruptCachedSession(id, reason) {
|
|
@@ -145,7 +146,7 @@ function corruptCachedSession(id, reason) {
|
|
|
145
146
|
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
146
147
|
parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
|
|
147
148
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
148
|
-
note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
|
|
149
|
+
note: `session record is unreadable: ${reason}`, archived: false, closedAt: null, archiveHazard: null,
|
|
149
150
|
prompt: null, promptPreview: null, created: 0, activity: null, sortKey: null,
|
|
150
151
|
};
|
|
151
152
|
}
|
|
@@ -154,18 +155,59 @@ function corruptCachedSession(id, reason) {
|
|
|
154
155
|
// follow exists to eliminate. Every field here comes from the store — nothing is probed.
|
|
155
156
|
export function localCachedSessions(includeArchived = false) {
|
|
156
157
|
const rows = [];
|
|
158
|
+
// The runtime envelope is deliberately metadata-only after migration. A live session may therefore have
|
|
159
|
+
// a canonical application row before its runtime.json is materialized (or after it is retired). Selectors
|
|
160
|
+
// must still resolve that row; treating the missing envelope as "no such session" strands watch/cancel.
|
|
161
|
+
// Do not create a fresh SQLite store while merely reading an empty project. A canonical application is
|
|
162
|
+
// needed only when a marker or legacy records prove that this project already has session state.
|
|
163
|
+
const cutover = sessionApplicationCutoverState();
|
|
164
|
+
const application = cutover === 'fresh' ? undefined : configuredSessionApplicationIfCutover();
|
|
157
165
|
for (const id of listSessionIds()) {
|
|
158
166
|
const entry = readPublicRecordEntry(id);
|
|
159
167
|
if (entry.kind === 'corrupt') {
|
|
160
168
|
rows.push(corruptCachedSession(id, entry.error));
|
|
161
169
|
continue;
|
|
162
170
|
}
|
|
163
|
-
if (entry.kind
|
|
171
|
+
if (entry.kind === 'ok') {
|
|
172
|
+
const rec = fromRaw(entry.raw);
|
|
173
|
+
if (!rec.governed)
|
|
174
|
+
continue;
|
|
175
|
+
rows.push(toSession(rec, cachedStatus(rec), 'unknown'));
|
|
164
176
|
continue;
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
}
|
|
178
|
+
const state = application?.readState(id);
|
|
179
|
+
if (!state)
|
|
167
180
|
continue;
|
|
168
|
-
|
|
181
|
+
const lifecycle = state.status;
|
|
182
|
+
const status = state.status === 'awaiting'
|
|
183
|
+
? displayStatusForProposal(state.proposal)
|
|
184
|
+
: state.status === 'archived' ? 'offline' : state.status;
|
|
185
|
+
rows.push({
|
|
186
|
+
id,
|
|
187
|
+
node: null,
|
|
188
|
+
branch: null,
|
|
189
|
+
path: '',
|
|
190
|
+
label: id,
|
|
191
|
+
title: id,
|
|
192
|
+
raw: { name: null, title: null },
|
|
193
|
+
parent: state.parentSessionId,
|
|
194
|
+
harness: 'unknown',
|
|
195
|
+
capabilities: { headless: false },
|
|
196
|
+
launcher: null,
|
|
197
|
+
lifecycle,
|
|
198
|
+
proposal: state.proposal,
|
|
199
|
+
merges: 0,
|
|
200
|
+
status,
|
|
201
|
+
liveness: 'unknown',
|
|
202
|
+
note: state.note,
|
|
203
|
+
archived: state.status === 'archived',
|
|
204
|
+
closedAt: null,
|
|
205
|
+
prompt: null,
|
|
206
|
+
promptPreview: null,
|
|
207
|
+
created: state.updatedAtMs,
|
|
208
|
+
activity: null,
|
|
209
|
+
sortKey: null,
|
|
210
|
+
});
|
|
169
211
|
}
|
|
170
212
|
return rows.filter((session) => includeArchived || !session.archived)
|
|
171
213
|
.sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id));
|
|
@@ -226,20 +268,6 @@ export async function clientListSessions(includeArchived = false) {
|
|
|
226
268
|
return await r.json();
|
|
227
269
|
}, () => localCachedSessions(includeArchived));
|
|
228
270
|
}
|
|
229
|
-
// A terminal close removes the record from the board. The audit lookup is deliberately id-addressed and is
|
|
230
|
-
// consulted only after an explicit selector missed that board, so ordinary lists stay a current-state read.
|
|
231
|
-
export async function clientSessionClosure(selector) {
|
|
232
|
-
return cachedRead(async () => {
|
|
233
|
-
const r = await apiFetch(`/api/sessions/${seg(selector)}/closure`);
|
|
234
|
-
if (r.headers.get('x-spexcode-close-history') !== 'v1')
|
|
235
|
-
throw new BackendError(`backend does not support terminal close history; refusing to label ${selector} as never existed (update the backend)`, 501);
|
|
236
|
-
if (r.status === 404)
|
|
237
|
-
return null;
|
|
238
|
-
if (!r.ok)
|
|
239
|
-
throw new BackendError(`backend error ${r.status} retrieving close history for ${selector}`, r.status);
|
|
240
|
-
return await r.json();
|
|
241
|
-
}, () => findSessionClosure(selector));
|
|
242
|
-
}
|
|
243
271
|
export async function clientResources() {
|
|
244
272
|
return cachedRead(async () => {
|
|
245
273
|
const r = await apiFetch('/api/resources');
|
|
@@ -429,15 +457,6 @@ export async function clientRestoreQuarantine(id) {
|
|
|
429
457
|
throw new BackendError(`backend returned an invalid quarantine restore for ${id}`, r.status);
|
|
430
458
|
return { id: body.id, bundle: body.bundle, sha256: body.sha256, observedAt: body.observedAt };
|
|
431
459
|
}
|
|
432
|
-
// POST /api/sessions/:id/archive — cold-archive the session ([[archive]]). The legacy on=false spelling is a
|
|
433
|
-
// signpost to the same resume transition; it never performs a record-only unarchive.
|
|
434
|
-
export async function clientArchive(id, on = true) {
|
|
435
|
-
await guarded(on ? 'session archive' : 'session unarchive');
|
|
436
|
-
const r = await apiFetch(`/api/sessions/${seg(id)}/archive`, post({ on }));
|
|
437
|
-
if (!r.ok)
|
|
438
|
-
throw new BackendError(`backend refused to ${on ? 'archive' : 'unarchive'} ${id}: ${await r.text()}`, r.status);
|
|
439
|
-
return !!(await r.json().catch(() => ({ ok: false })))?.ok;
|
|
440
|
-
}
|
|
441
460
|
// POST /api/sessions/:id/rename — set (or clear, with a blank) the session's display-name override
|
|
442
461
|
// ([[session-rename]] as a CLI verb). {ok:false} = no such session (404).
|
|
443
462
|
export async function clientRename(id, name) {
|
|
@@ -31,6 +31,8 @@ export type CodexGenerationLedger = Readonly<{
|
|
|
31
31
|
export declare function codexGenerationSocketPath(root: string, generationId?: string): string;
|
|
32
32
|
export declare function legacyCodexGenerationEndpoint(root: string): CodexGenerationEndpoint;
|
|
33
33
|
export declare function readCodexGenerationLedger(root: string): CodexGenerationLedger;
|
|
34
|
+
export declare function ensureCodexGenerationLedger(root: string): Promise<CodexGenerationLedger>;
|
|
35
|
+
export declare function withCodexGenerationMutex<T>(root: string, body: () => Promise<T>): Promise<T>;
|
|
34
36
|
export type CodexGenerationRotation = Readonly<{
|
|
35
37
|
previous: CodexGenerationEndpoint;
|
|
36
38
|
current: CodexGenerationEndpoint;
|
|
@@ -55,4 +57,13 @@ export declare function reclaimDrainingCodexGeneration(root: string, generationI
|
|
|
55
57
|
reclaimed: boolean;
|
|
56
58
|
reason: string;
|
|
57
59
|
}>;
|
|
60
|
+
export declare function reclaimDrainingCodexGenerations(root: string, census: (endpoint: CodexGenerationEndpoint) => Promise<{
|
|
61
|
+
healthy: boolean;
|
|
62
|
+
referenceIds: string[];
|
|
63
|
+
peerCount: number;
|
|
64
|
+
}>): Promise<Array<{
|
|
65
|
+
generationId: string;
|
|
66
|
+
reclaimed: boolean;
|
|
67
|
+
reason: string;
|
|
68
|
+
}>>;
|
|
58
69
|
export {};
|
|
@@ -82,6 +82,16 @@ export function readCodexGenerationLedger(root) {
|
|
|
82
82
|
return emptyLedger();
|
|
83
83
|
return parseLedger(JSON.parse(readFileSync(ledgerPath(root), 'utf8')));
|
|
84
84
|
}
|
|
85
|
+
// The backend startup sweep must see legacy roots too. Bootstrap only when the legacy residue is present; an
|
|
86
|
+
// empty project remains an empty ledger and no runtime is created by inspection.
|
|
87
|
+
export async function ensureCodexGenerationLedger(root) {
|
|
88
|
+
return withLedgerLock(root, async () => {
|
|
89
|
+
const current = readCodexGenerationLedger(root);
|
|
90
|
+
if (existsSync(ledgerPath(root)) || current.revision !== 0)
|
|
91
|
+
return current;
|
|
92
|
+
return writeLedger(root, current, bootstrapLedger(root));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
85
95
|
function writeLedger(root, previous, next) {
|
|
86
96
|
const observed = readCodexGenerationLedger(root);
|
|
87
97
|
if (observed.revision !== previous.revision)
|
|
@@ -244,6 +254,12 @@ async function withLedgerLock(root, body) {
|
|
|
244
254
|
releaseLedgerLock(root, owner);
|
|
245
255
|
}
|
|
246
256
|
}
|
|
257
|
+
// Codex accepts only a bounded amount of concurrent thread/start work. The generation ledger lock is already
|
|
258
|
+
// the cross-process project mutex with exact stale-owner recovery, so use it for native thread admission too.
|
|
259
|
+
// This keeps launch serialization in the runtime owner rather than making callers invent their own queue.
|
|
260
|
+
export async function withCodexGenerationMutex(root, body) {
|
|
261
|
+
return withLedgerLock(root, body);
|
|
262
|
+
}
|
|
247
263
|
function withLedgerLockSync(root, body) {
|
|
248
264
|
const owner = acquireLedgerLockSync(root);
|
|
249
265
|
try {
|
|
@@ -317,7 +333,7 @@ function bootstrapBindings(root, generationId) {
|
|
|
317
333
|
}
|
|
318
334
|
for (const sessionId of ids) {
|
|
319
335
|
try {
|
|
320
|
-
const record = JSON.parse(readFileSync(join(sessions, sessionId, '
|
|
336
|
+
const record = JSON.parse(readFileSync(join(sessions, sessionId, 'runtime.json'), 'utf8'));
|
|
321
337
|
const harness = record.harness;
|
|
322
338
|
const threadId = record.harness_session_id;
|
|
323
339
|
if (record.governed === true && (harness === 'codex' || harness === 'codex-headless') && typeof threadId === 'string' && threadId) {
|
|
@@ -328,20 +344,28 @@ function bootstrapBindings(root, generationId) {
|
|
|
328
344
|
}
|
|
329
345
|
return result;
|
|
330
346
|
}
|
|
331
|
-
function
|
|
347
|
+
function readBindingRecord(root, sessionId) {
|
|
332
348
|
if (!sessionId || sessionId.includes('/') || sessionId.includes('\\'))
|
|
333
|
-
return
|
|
349
|
+
return null;
|
|
334
350
|
try {
|
|
335
|
-
|
|
336
|
-
const harness = record.harness;
|
|
337
|
-
return record.governed === true && (harness === 'codex' || harness === 'codex-headless') && record.harness_session_id === binding.threadId;
|
|
351
|
+
return JSON.parse(readFileSync(join(root, 'sessions', sessionId, 'runtime.json'), 'utf8'));
|
|
338
352
|
}
|
|
339
353
|
catch {
|
|
340
|
-
return
|
|
354
|
+
return null;
|
|
341
355
|
}
|
|
342
356
|
}
|
|
357
|
+
function bindingRecordMatches(record, binding) {
|
|
358
|
+
const harness = record?.harness;
|
|
359
|
+
return !!record && record.governed === true && (harness === 'codex' || harness === 'codex-headless') && record.harness_session_id === binding.threadId;
|
|
360
|
+
}
|
|
343
361
|
function bindingProtectsGeneration(root, sessionId, binding) {
|
|
344
|
-
|
|
362
|
+
// A close transaction remains protective until its record is physically removed. Once a historical
|
|
363
|
+
// record is archived, its durable rollout is still recoverable but the record no longer routes or pins
|
|
364
|
+
// a live generation; the native census remains the independent guard for any still-loaded thread.
|
|
365
|
+
const record = readBindingRecord(root, sessionId);
|
|
366
|
+
if (!record)
|
|
367
|
+
return binding.phase !== 'record-pending' && binding.phase !== 'record-removing';
|
|
368
|
+
return bindingRecordMatches(record, binding) && (binding.phase === 'record-removing' || record.archived !== true);
|
|
345
369
|
}
|
|
346
370
|
function bootstrapLedger(root) {
|
|
347
371
|
const legacy = legacyCodexGenerationEndpoint(root);
|
|
@@ -626,7 +650,7 @@ export function bindCodexGeneration(root, sessionId, threadId, generationId) {
|
|
|
626
650
|
writeLedger(root, previous, { current: previous.current, pending: previous.pending, generations: previous.generations, bindings });
|
|
627
651
|
});
|
|
628
652
|
}
|
|
629
|
-
// Native thread creation and
|
|
653
|
+
// Native thread creation and runtime envelope persistence are separate writes. A pending registration can be
|
|
630
654
|
// recovered only when the durable record names the same exact thread; otherwise it cannot pin a root forever.
|
|
631
655
|
export function prepareCodexGenerationRegistration(root, sessionId, threadId, generationId) {
|
|
632
656
|
withLedgerLockSync(root, () => {
|
|
@@ -796,3 +820,16 @@ export async function reclaimDrainingCodexGeneration(root, generationId, census)
|
|
|
796
820
|
return { reclaimed: true, reason: 'exact drained generation reclaimed after zero-reference census' };
|
|
797
821
|
});
|
|
798
822
|
}
|
|
823
|
+
// Startup/rotation callers use this sweep to make stale generation cleanup a property of the shared runtime
|
|
824
|
+
// owner. The census remains adapter-supplied and fail-closed; a missing or unhealthy descriptor is retained.
|
|
825
|
+
export async function reclaimDrainingCodexGenerations(root, census) {
|
|
826
|
+
const generations = readCodexGenerationLedger(root).generations;
|
|
827
|
+
const results = [];
|
|
828
|
+
for (const [generationId, generation] of Object.entries(generations)) {
|
|
829
|
+
if (generation.state !== 'draining')
|
|
830
|
+
continue;
|
|
831
|
+
const result = await reclaimDrainingCodexGeneration(root, generationId, census);
|
|
832
|
+
results.push({ generationId, ...result });
|
|
833
|
+
}
|
|
834
|
+
return results;
|
|
835
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, openSync, readFileSync, unlinkSync, writeSync, closeSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runtimeRoot } from '@spexcode/spec-core';
|
|
4
|
+
const lockRoot = () => join(runtimeRoot(), '.delivery-locks');
|
|
5
|
+
const lockPath = (id) => join(lockRoot(), `${id}.lock`);
|
|
6
|
+
const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
7
|
+
async function acquire(id, timeoutMs) {
|
|
8
|
+
mkdirSync(lockRoot(), { recursive: true });
|
|
9
|
+
const path = lockPath(id), deadline = Date.now() + timeoutMs;
|
|
10
|
+
for (;;) {
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(path, 'wx');
|
|
13
|
+
writeSync(fd, String(process.pid));
|
|
14
|
+
closeSync(fd);
|
|
15
|
+
return () => { try {
|
|
16
|
+
unlinkSync(path);
|
|
17
|
+
}
|
|
18
|
+
catch { /* a dead owner may have reclaimed it */ } };
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code !== 'EEXIST')
|
|
22
|
+
throw error;
|
|
23
|
+
let owner = 0;
|
|
24
|
+
try {
|
|
25
|
+
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
26
|
+
}
|
|
27
|
+
catch { /* creator/releaser race */ }
|
|
28
|
+
if (owner && owner !== process.pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(owner, 0);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(path);
|
|
35
|
+
}
|
|
36
|
+
catch { /* race */ }
|
|
37
|
+
;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (Date.now() >= deadline)
|
|
42
|
+
throw new Error(`delivery queue ${id}: timed out waiting for transaction lock`);
|
|
43
|
+
await pause(25);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Serialize delivery claims without making the transport itself part of SQLite. */
|
|
48
|
+
export async function withDeliveryLocks(rawIds, body, index = 0, ids = [...new Set(rawIds)].sort()) {
|
|
49
|
+
if (index >= ids.length)
|
|
50
|
+
return body();
|
|
51
|
+
const release = await acquire(ids[index], 30_000);
|
|
52
|
+
try {
|
|
53
|
+
return await withDeliveryLocks(ids, body, index + 1, ids);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
release();
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -3,9 +3,10 @@ import { join, dirname, basename } from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { loadSystemConfig, loadSkillConfig, loadSpecs } from '@spexcode/spec-core';
|
|
7
|
-
import {
|
|
6
|
+
import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadSpecs } from '@spexcode/spec-core';
|
|
7
|
+
import { treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from '@spexcode/spec-core';
|
|
8
8
|
import { loadConfig } from './lint.js';
|
|
9
|
+
import { GENERATED_MARK } from './harness.js';
|
|
9
10
|
import { trackedSourceFiles } from './source-files.js';
|
|
10
11
|
import { gitBinary } from '@spexcode/spec-core';
|
|
11
12
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
@@ -211,6 +212,25 @@ function manifestScripts(text) {
|
|
|
211
212
|
}
|
|
212
213
|
return [...out];
|
|
213
214
|
}
|
|
215
|
+
// which EVENTS the manifest actually binds. A tree can carry handlers and still be ungoverned in the way
|
|
216
|
+
// that matters, because a handler answers an event and the lifecycle rides on specific ones.
|
|
217
|
+
function manifestEvents(text) {
|
|
218
|
+
const out = new Set();
|
|
219
|
+
for (const line of text.split('\n')) {
|
|
220
|
+
const f = line.split('\t');
|
|
221
|
+
if (f.length >= 4 && f[0])
|
|
222
|
+
out.add(f[0]);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
// THE LIFECYCLE EVENTS, and why they are named here rather than derived from an adapter's full event list.
|
|
227
|
+
// A tree that binds no turn-end event has a shim, a manifest, and readable handlers — every check above it
|
|
228
|
+
// passes — and still cannot report that its agent stopped: the record keeps whatever state it last held and
|
|
229
|
+
// a dead turn still shows as running. That is not hypothetical; a deployment whose `.config` predated
|
|
230
|
+
// the stop-gate node hit exactly this, and nothing in the diagnosis said so. Stop is the gate; StopFailure
|
|
231
|
+
// is the failed-turn writer; PostToolUse is the freshness mark. Missing any of them is a distinct break
|
|
232
|
+
// from "no manifest", and it is the one a passing installation can hide.
|
|
233
|
+
const LIFECYCLE_EVENTS = ['Stop', 'StopFailure', 'PostToolUse'];
|
|
214
234
|
// the bundle's declared name, from plugin.json (root or the .claude-plugin/ convention), or null when none.
|
|
215
235
|
function pluginName(dir) {
|
|
216
236
|
for (const p of [join(dir, 'plugin.json'), join(dir, '.claude-plugin', 'plugin.json')]) {
|
|
@@ -263,6 +283,12 @@ async function doubleDeliveryReport(base) {
|
|
|
263
283
|
catch {
|
|
264
284
|
return [];
|
|
265
285
|
} })();
|
|
286
|
+
const ourAgents = (() => { try {
|
|
287
|
+
return loadAgentConfig().map((c) => c.name);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return [];
|
|
291
|
+
} })();
|
|
266
292
|
const L = [];
|
|
267
293
|
const line = (k, v) => L.push(` ${k.padEnd(16)}: ${v}`);
|
|
268
294
|
let conflict = false;
|
|
@@ -282,6 +308,13 @@ async function doubleDeliveryReport(base) {
|
|
|
282
308
|
...(looseDispatch ? [`loose ${rel(shimFile)}`] : []),
|
|
283
309
|
...bundles.filter((b) => b.hooksToDispatch).map((b) => `plugin "${b.name}" (${b.scope})`),
|
|
284
310
|
];
|
|
311
|
+
// a DIFFERENT collision from the double-delivery channels below: a skill/agent path a live spec node
|
|
312
|
+
// names, already occupied by a file the user wrote (no GENERATED_MARK). materialize skips those, so the
|
|
313
|
+
// node silently never reaches this harness — worth naming here, where somebody asks "do we clash?".
|
|
314
|
+
const userOwned = [
|
|
315
|
+
...(looseSkillDir ? ourSkills.map((s) => join(looseSkillDir, s, 'SKILL.md')) : []),
|
|
316
|
+
...((dir) => dir ? ourAgents.map((a) => join(dir, `${a}.md`)) : [])(h.agentDir(base)),
|
|
317
|
+
].filter((f) => existsSync(f) && !read(f).includes(GENERATED_MARK));
|
|
285
318
|
// channel 2 — same-named skill in loose skillDir AND a bundle's skills dir
|
|
286
319
|
const skillHits = [];
|
|
287
320
|
for (const s of ourSkills) {
|
|
@@ -302,6 +335,7 @@ async function doubleDeliveryReport(base) {
|
|
|
302
335
|
line(' plugin', `"${b.name}" (${b.scope}) — ${b.dir}`);
|
|
303
336
|
line('hooks→dispatch', `${hookSrc.length}${hookSrc.length > 1 ? ' (>1 → CONFLICT): ' + hookSrc.join(', ') : hookSrc.length === 1 ? ' (single — ok)' : ' (none wired)'}`);
|
|
304
337
|
line('skill shadowing', skillHits.length ? `CONFLICT: ${skillHits.join(', ')}` : 'none');
|
|
338
|
+
line('your files kept', userOwned.length ? `${userOwned.map(rel).join(', ')} — yours (no spexcode stamp), so the same-named spec node is NOT delivered here` : 'none');
|
|
305
339
|
L.push('');
|
|
306
340
|
}
|
|
307
341
|
if (conflict) {
|
|
@@ -423,31 +457,39 @@ async function doctor() {
|
|
|
423
457
|
const shim = read(h.shimFile(base));
|
|
424
458
|
line(`${h.id} shim`, /dispatch\.sh/.test(shim) ? `wired (${h.shimFile(base).replace(base + '/', '')})` : 'NOT wired (no dispatch shim)');
|
|
425
459
|
}
|
|
426
|
-
//
|
|
427
|
-
// (a pre-slot tree's migration-window fallback) — so the doctor reads exactly what a dispatch would.
|
|
460
|
+
// Manifest resolution mirrors dispatch.sh: only the current tree slot is executable.
|
|
428
461
|
let manifestText = '';
|
|
429
462
|
let manifestHome = 'tree slot';
|
|
430
463
|
try {
|
|
431
464
|
manifestText = read(join(treeSlotDir(base), 'hooks-manifest'));
|
|
432
465
|
}
|
|
433
466
|
catch { /* non-git / no store */ }
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
manifestText = read(join(runtimeRoot(base), 'hooks-manifest'));
|
|
437
|
-
manifestHome = 'legacy global file (pre-slot materialize — re-run `spex materialize`)';
|
|
438
|
-
}
|
|
439
|
-
catch { /* neither */ }
|
|
467
|
+
const manifestFile = (() => { try {
|
|
468
|
+
return join(treeSlotDir(base), 'hooks-manifest');
|
|
440
469
|
}
|
|
470
|
+
catch {
|
|
471
|
+
return null;
|
|
472
|
+
} })();
|
|
473
|
+
const manifestPresent = !!manifestFile && existsSync(manifestFile);
|
|
441
474
|
if (!manifestText) {
|
|
442
|
-
|
|
475
|
+
// ABSENT and EMPTY are different breaks and want different repairs, and the dispatcher cannot tell them
|
|
476
|
+
// apart at all: a missing file is a loud exit 78, an empty one dispatches nothing and exits 0 in silence.
|
|
477
|
+
line('manifest', manifestPresent
|
|
478
|
+
? 'EMPTY in the current tree slot — materialize ran and bound ZERO handlers: this tree has no hook nodes, so every event dispatches nothing and exits 0 in silence'
|
|
479
|
+
: 'MISSING from the current tree slot — materialize must run before hooks can execute');
|
|
443
480
|
}
|
|
444
481
|
else {
|
|
445
482
|
const scripts = manifestScripts(manifestText);
|
|
446
483
|
const missing = scripts.filter((s) => !existsSync(join(base, s)));
|
|
484
|
+
const events = manifestEvents(manifestText);
|
|
447
485
|
line('manifest', `${scripts.length} handler(s) in the ${manifestHome}`);
|
|
448
486
|
line('handlers', missing.length === 0 ? 'all readable in the worktree' : `${missing.length} MISSING in the worktree → those hooks SILENTLY NO-OP:`);
|
|
449
487
|
for (const m of missing)
|
|
450
488
|
L.push(` ✗ ${m}`);
|
|
489
|
+
const unbound = LIFECYCLE_EVENTS.filter((event) => !events.has(event));
|
|
490
|
+
line('lifecycle events', unbound.length === 0
|
|
491
|
+
? `${LIFECYCLE_EVENTS.join(', ')} all bound`
|
|
492
|
+
: `${unbound.join(', ')} NOT bound → this tree cannot report its own turn ending; the record keeps its last state and a stopped agent still shows as running`);
|
|
451
493
|
}
|
|
452
494
|
// codex trust
|
|
453
495
|
const trustPresent = codexCfg.includes(`# spexcode:trust:${base}`);
|
package/dist/file-write.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
|
-
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, copyFileSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
// A differing target is replaced by rename, never rewritten in place: a concurrent reader — codex re-reading its
|
|
4
|
+
// own config.toml, a harness loading its hooks file — sees the old bytes or the new bytes, never a truncated
|
|
5
|
+
// middle. The replacement lands on the real file behind a symlink and keeps that file's mode.
|
|
2
6
|
export function writeFileIfChanged(path, content) {
|
|
3
7
|
const next = Buffer.from(content);
|
|
8
|
+
let target = path;
|
|
9
|
+
let mode;
|
|
4
10
|
try {
|
|
5
11
|
if (readFileSync(path).equals(next))
|
|
6
12
|
return false;
|
|
13
|
+
target = realpathSync(path);
|
|
14
|
+
mode = statSync(target).mode;
|
|
7
15
|
}
|
|
8
16
|
catch (error) {
|
|
9
17
|
if (error?.code !== 'ENOENT')
|
|
10
18
|
throw error;
|
|
11
19
|
}
|
|
12
|
-
|
|
20
|
+
const staged = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
|
|
21
|
+
writeFileSync(staged, next);
|
|
22
|
+
if (mode !== undefined)
|
|
23
|
+
chmodSync(staged, mode);
|
|
24
|
+
renameSync(staged, target);
|
|
13
25
|
return true;
|
|
14
26
|
}
|
|
15
27
|
export function copyFileIfChanged(source, target) {
|
package/dist/gateway-hub.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// mounted, an explicit text/html GET gets the Projects UI instead
|
|
12
12
|
// PUT|DELETE /projects/admin-password admin: set/clear the admin password
|
|
13
13
|
// PUT|DELETE /projects/:id/password admin: set/clear one project's password
|
|
14
|
+
// DELETE /projects/:id host extension: high-friction catalog registration removal
|
|
14
15
|
// /p/:projectId/login|logout project session for that project
|
|
15
16
|
// ANY /p/:projectId/* (+ WS upgrade) authorized → proxied, prefix-stripped, to that project's backend
|
|
16
17
|
// Authorization never trusts the cookie's name or Path — the token's projectId claim is validated against
|
|
@@ -332,7 +333,7 @@ export function startHubGateway(opts) {
|
|
|
332
333
|
host: opts.host,
|
|
333
334
|
label: opts.label ?? 'hub gateway',
|
|
334
335
|
cleanup: opts.onBindFail,
|
|
335
|
-
ready: `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${
|
|
336
|
+
ready: (actualPort) => `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${actualPort} — /projects + /p/:projectId/*`,
|
|
336
337
|
});
|
|
337
338
|
return server;
|
|
338
339
|
}
|
package/dist/gateway.js
CHANGED
|
@@ -181,9 +181,12 @@ export function startGateway(opts) {
|
|
|
181
181
|
const scheme = secure ? 'https' : 'http';
|
|
182
182
|
const label = opts.label ?? 'public mode';
|
|
183
183
|
const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
|
|
184
|
-
const ready =
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
const ready = (port) => {
|
|
185
|
+
const lines = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${port}${gate}, proxying /api to :${opts.upstreamPort}`];
|
|
186
|
+
if (!secure && !isLoopback && !opts.host)
|
|
187
|
+
lines.push('[gateway] (TLS off — --http)');
|
|
188
|
+
return lines;
|
|
189
|
+
};
|
|
187
190
|
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
188
191
|
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
|
|
189
192
|
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
|
package/dist/graphCache.js
CHANGED
|
@@ -11,6 +11,7 @@ import { residentForgeState } from '@spexcode/spec-forge/resident';
|
|
|
11
11
|
import { resolveProjectIdentity } from '@spexcode/spec-core';
|
|
12
12
|
import { readReviewSnapshot } from '@spexcode/spec-core';
|
|
13
13
|
import { sessionEvalProjection } from '@spexcode/spec-eval/sessioneval';
|
|
14
|
+
import { resolveDatabasePath } from '@spexcode/session-selflaunch';
|
|
14
15
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1';
|
|
15
16
|
function textOrNull(path) {
|
|
16
17
|
try {
|
|
@@ -128,6 +129,28 @@ function strictSpecTreeRevision(wtPath) {
|
|
|
128
129
|
}
|
|
129
130
|
return parts.sort().join('\n');
|
|
130
131
|
}
|
|
132
|
+
// The canonical session database ([[production-cutin]]) is a board input exactly like the runtime envelope:
|
|
133
|
+
// lifecycle status lives there, and ANY process may commit to it — a hook's `spex internal session-state`, a CLI
|
|
134
|
+
// declaration, the backend's own routes. Its file identity folds into the session revision, so a commit no leaf
|
|
135
|
+
// pushed (graph-stream's `session-db` leaf held or disabled) is still a moved revision that the patrol answers
|
|
136
|
+
// with the sessions splice. journal_mode=delete rewrites the file in place on every commit, so this is the same
|
|
137
|
+
// strict stat contract as the `.spec` walk: ctime catches a same-size rewrite, ENOENT is a legitimate fresh
|
|
138
|
+
// store, and every other read failure is loud.
|
|
139
|
+
function strictFileRevision(path) {
|
|
140
|
+
try {
|
|
141
|
+
const stat = statSync(path);
|
|
142
|
+
return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code === 'ENOENT')
|
|
146
|
+
return '';
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function sessionDatabaseRevision() {
|
|
151
|
+
const databasePath = resolveDatabasePath();
|
|
152
|
+
return [databasePath, strictFileRevision(databasePath)];
|
|
153
|
+
}
|
|
131
154
|
function worktreeRevision(root) {
|
|
132
155
|
return {
|
|
133
156
|
root,
|
|
@@ -153,12 +176,17 @@ function sessionInputRevision() {
|
|
|
153
176
|
textOrNull(sessionRecordPath(id)),
|
|
154
177
|
textOrNull(sessionArtifactPath(id, 'prompt')),
|
|
155
178
|
]);
|
|
179
|
+
// The canonical database carries the lifecycle those envelopes no longer do, and a commit from ANY process
|
|
180
|
+
// moves it. It is an input only while a record exists to read it through: with no session records the board
|
|
181
|
+
// has no row that derives from the store, and the store's own birth — the first canonical access inside a
|
|
182
|
+
// build initializes it — must not read as an input that moved during that build.
|
|
183
|
+
const canonical = ids.length ? sessionDatabaseRevision() : null;
|
|
156
184
|
const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]));
|
|
157
185
|
const activeRoots = [...new Set(ids.flatMap((id) => {
|
|
158
186
|
const entry = readPublicRecordEntry(id);
|
|
159
187
|
return entry.kind === 'ok' && entry.raw.governed && !entry.raw.archived ? [entry.raw.worktree_path] : [];
|
|
160
188
|
}))].sort();
|
|
161
|
-
return { sessions: digest(sessionInputs), projections, projectionIds: ids, activeRoots };
|
|
189
|
+
return { sessions: digest([canonical, sessionInputs]), projections, projectionIds: ids, activeRoots };
|
|
162
190
|
}
|
|
163
191
|
function boardInputRevision(board) {
|
|
164
192
|
const root = repoRoot();
|
|
@@ -444,6 +472,8 @@ function startBuild(mode = 'dirty') {
|
|
|
444
472
|
});
|
|
445
473
|
// Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
|
|
446
474
|
// loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
|
|
475
|
+
// A direct cold reader may be the only live work in a short-lived CLI process. Keep this one-shot
|
|
476
|
+
// scheduler referenced until it starts the producer, otherwise Node can exit with its fresh read pending.
|
|
447
477
|
setTimeout(() => {
|
|
448
478
|
if (controller.signal.aborted) {
|
|
449
479
|
rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }));
|
|
@@ -547,7 +577,7 @@ function startBuild(mode = 'dirty') {
|
|
|
547
577
|
catch (error) {
|
|
548
578
|
rejectBuild(error);
|
|
549
579
|
}
|
|
550
|
-
}, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS)
|
|
580
|
+
}, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS);
|
|
551
581
|
const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`);
|
|
552
582
|
// `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
|
|
553
583
|
// promise settles, so a next read can never overlap an abandoned git/fs build.
|
package/dist/graphSnapshot.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
|
|
2
4
|
import { resolveLayout } from '@spexcode/spec-core';
|
|
3
5
|
import { listSessions } from './sessions.js';
|
|
4
6
|
import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
|
|
@@ -9,6 +11,46 @@ import { buildBoard as assembleBoard, spliceSessions as spliceBoardSessions } fr
|
|
|
9
11
|
import { evalContext, evalTimelines } from '@spexcode/spec-eval/evaltab';
|
|
10
12
|
import { evalNodesAsync } from '@spexcode/spec-eval/scenarios';
|
|
11
13
|
import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
|
|
14
|
+
import { evalRemarkSourceFingerprint } from '@spexcode/spec-eval/host';
|
|
15
|
+
import { listBlobs } from '@spexcode/spec-eval/cache';
|
|
16
|
+
let timelineCache = null;
|
|
17
|
+
function fileFingerprint(path) {
|
|
18
|
+
try {
|
|
19
|
+
return readFileSync(path).toString('base64');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '<missing>';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function timelineCacheKey(root, nodeIds, specs, evalNodes) {
|
|
26
|
+
const hash = createHash('sha256');
|
|
27
|
+
hash.update(root);
|
|
28
|
+
hash.update('\0');
|
|
29
|
+
hash.update(headSha(root) || '<no-head>');
|
|
30
|
+
hash.update('\0');
|
|
31
|
+
hash.update(JSON.stringify(nodeIds));
|
|
32
|
+
for (const spec of specs) {
|
|
33
|
+
hash.update('\0spec\0');
|
|
34
|
+
hash.update(spec.path);
|
|
35
|
+
hash.update('\0');
|
|
36
|
+
hash.update(spec.body);
|
|
37
|
+
}
|
|
38
|
+
for (const node of evalNodes) {
|
|
39
|
+
hash.update('\0eval\0');
|
|
40
|
+
hash.update(node.id);
|
|
41
|
+
hash.update('\0');
|
|
42
|
+
hash.update(node.evalSource ?? '');
|
|
43
|
+
hash.update('\0');
|
|
44
|
+
hash.update(fileFingerprint(node.sidecarPath));
|
|
45
|
+
}
|
|
46
|
+
// Issue remarks are an eval input even though they live outside the spec tree.
|
|
47
|
+
hash.update('\0remarks\0');
|
|
48
|
+
hash.update(evalRemarkSourceFingerprint());
|
|
49
|
+
// Evidence presence is part of each published timeline row (`present` vs `miss`).
|
|
50
|
+
hash.update('\0blobs\0');
|
|
51
|
+
hash.update(listBlobs().join('\0'));
|
|
52
|
+
return hash.digest('hex');
|
|
53
|
+
}
|
|
12
54
|
// The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
|
|
13
55
|
export async function boardSnapshot() {
|
|
14
56
|
const root = repoRoot();
|
|
@@ -22,7 +64,20 @@ export async function boardSnapshot() {
|
|
|
22
64
|
const { issues, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds);
|
|
23
65
|
const [idx, hidx, evalNodes] = await Promise.all([driftIndex(root), historyIndex(root), evalNodesAsync(root)]);
|
|
24
66
|
const context = await evalContext(root, specs, idx, hidx, undefined, evalNodes);
|
|
25
|
-
const
|
|
67
|
+
const key = timelineCacheKey(root, nodeIds, specs, evalNodes);
|
|
68
|
+
let timelines;
|
|
69
|
+
if (timelineCache?.key === key) {
|
|
70
|
+
timelines = timelineCache.timelines;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// The board carries only latest-per-scenario counts, so the cold build reads latest-only: the retained
|
|
74
|
+
// sidecar history is served by the detail endpoints and probing it here scales graph latency with the
|
|
75
|
+
// reading count instead of the live verdict population. Freshness itself is NOT deferred — the board
|
|
76
|
+
// publishes verdicts, and a verdict whose freshness was never computed is not a stale verdict, it is no
|
|
77
|
+
// verdict at all. An order-only board would report every measured row as stale and none as fresh.
|
|
78
|
+
timelines = await evalTimelines(nodeIds, context, { latestOnly: true });
|
|
79
|
+
timelineCache = { key, timelines };
|
|
80
|
+
}
|
|
26
81
|
return {
|
|
27
82
|
root, specs, layout, sessions, issues, issuesStamp, forgeRevision: residentForgeRevision(),
|
|
28
83
|
evalTimelines: new Map(nodeIds.map((nodeId, index) => [nodeId, timelines[index]])),
|