spexcode 0.2.8 → 0.3.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/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
2
|
-
import type { SessionEvals } from '../../spec-
|
|
2
|
+
import type { SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
3
3
|
|
|
4
4
|
export class BackendError extends Error {
|
|
5
5
|
constructor(message: string, readonly status?: number) {
|
|
@@ -25,7 +25,7 @@ const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
|
|
|
25
25
|
const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
26
26
|
const seg = (id: string) => encodeURIComponent(id)
|
|
27
27
|
|
|
28
|
-
// GET /api/sessions — the board, used by `spex ls`, and by `spex watch`/`wait` as their poll source.
|
|
28
|
+
// GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
|
|
29
29
|
export async function clientListSessions(): Promise<Session[]> {
|
|
30
30
|
const r = await apiFetch('/api/sessions')
|
|
31
31
|
if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
|
|
@@ -46,13 +46,13 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
|
|
|
46
46
|
return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// POST /api/sessions/:id/
|
|
50
|
-
// socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
|
|
49
|
+
// POST /api/sessions/:id/input {kind:"text"} — prompt dispatch (the backend routes it through the rendezvous
|
|
50
|
+
// socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
|
|
51
51
|
export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
52
52
|
await guarded('session send')
|
|
53
53
|
// `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
|
|
54
54
|
// it's present (an agent send), so a human-shell send stays unrecorded.
|
|
55
|
-
const r = await apiFetch(`/api/sessions/${seg(id)}/
|
|
55
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
|
|
56
56
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -64,18 +64,18 @@ export async function clientReview(id: string): Promise<ReviewPayload | null> {
|
|
|
64
64
|
return await r.json() as ReviewPayload
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// GET /api/sessions/:id/
|
|
68
|
-
// HTML the backend builds
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
export
|
|
72
|
-
|
|
73
|
-
const r = await apiFetch(`/api/sessions/${seg(id)}/proof${json ? '?format=json' : ''}`)
|
|
67
|
+
// GET /api/sessions/:id/evals?format=html — the rendered EXPORT artifact ([[session-eval]]): the
|
|
68
|
+
// self-contained HTML the backend builds. The engine runs on the backend, so the CLI is a thin fetcher
|
|
69
|
+
// that writes/opens these bytes — works against a remote backend unchanged. 404 → no such session.
|
|
70
|
+
export type ExportResult = { ok: true; body: string } | { ok: false; status: number }
|
|
71
|
+
export async function clientEvalExport(id: string): Promise<ExportResult> {
|
|
72
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/evals?format=html`)
|
|
74
73
|
if (r.ok) return { ok: true, body: await r.text() }
|
|
75
74
|
return { ok: false, status: r.status }
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
// GET /api/sessions/:id/evals — the session EVAL model ([[
|
|
77
|
+
// GET /api/sessions/:id/evals — the session EVAL model ([[session-eval]]'s interactive face, the same
|
|
78
|
+
// route's default JSON representation): the changed
|
|
79
79
|
// nodes' worktree-rooted reading rows (each carrying `inSession`), no diff enrichment, no inlined evidence
|
|
80
80
|
// bytes — what `spex eval` renders, the dashboard Eval tab's source. 404 → no such session.
|
|
81
81
|
export type EvalsResult = { ok: true; model: SessionEvals } | { ok: false; status: number }
|
|
@@ -95,17 +95,17 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
95
95
|
// POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
|
|
96
96
|
// working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
|
|
97
97
|
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
|
|
98
|
-
export async function
|
|
99
|
-
await guarded('session
|
|
98
|
+
export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
99
|
+
await guarded('session resume')
|
|
100
100
|
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
101
101
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// POST /api/sessions/:id/
|
|
104
|
+
// POST /api/sessions/:id/stop — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
|
|
105
105
|
// resumable). Distinct from close. {ok:false} = no such session.
|
|
106
|
-
export async function
|
|
107
|
-
await guarded('session
|
|
108
|
-
const r = await apiFetch(`/api/sessions/${seg(id)}/
|
|
106
|
+
export async function clientStop(id: string): Promise<boolean> {
|
|
107
|
+
await guarded('session stop')
|
|
108
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
|
|
109
109
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -124,19 +124,20 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
|
|
|
124
124
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// POST /api/sessions/:id/
|
|
128
|
-
// ordered token batch drives an interactive TUI menu
|
|
129
|
-
// session, no live pane, or no valid token delivered.
|
|
130
|
-
export async function
|
|
131
|
-
await guarded('session
|
|
132
|
-
const r = await apiFetch(`/api/sessions/${seg(id)}/
|
|
127
|
+
// POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
|
|
128
|
+
// NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
|
|
129
|
+
// ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
|
|
130
|
+
export async function clientSendRawKeys(id: string, keys: string[]): Promise<boolean> {
|
|
131
|
+
await guarded('session send')
|
|
132
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'keys', keys }))
|
|
133
133
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// GET /api/sessions/:id
|
|
137
|
-
|
|
138
|
-
export
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
// GET /api/sessions/:id — the session RECORD detail (`spex session show`): the board row plus the full
|
|
137
|
+
// originating prompt. 404 → no such session.
|
|
138
|
+
export type ShowResult = { ok: true; session: Session & { prompt: string | null } } | { ok: false; status: number }
|
|
139
|
+
export async function clientShow(id: string): Promise<ShowResult> {
|
|
140
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}`)
|
|
141
|
+
if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
|
|
141
142
|
return { ok: false, status: r.status }
|
|
142
143
|
}
|
|
@@ -138,7 +138,7 @@ export function settleIndexStat(proj: string, files: string[]): void {
|
|
|
138
138
|
|
|
139
139
|
// the full inverse (edge ③ — call AFTER the managed blocks left the working files): attribute lines out,
|
|
140
140
|
// config keys unset, shim + block content removed. `<common>/spexcode/` may host other spexcode data
|
|
141
|
-
// (
|
|
141
|
+
// (evidence blobs), so only OUR two files go, never the dir.
|
|
142
142
|
export function removeContractFilter(proj: string): void {
|
|
143
143
|
let common: string
|
|
144
144
|
try { common = commonDirOf(proj) } catch { return } // not a git repo → nothing was ever planted
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
13
13
|
import { execFileSync } from 'node:child_process'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import { loadSystemConfig, loadSkillConfig } from './specs.js'
|
|
16
|
-
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord } from './layout.js'
|
|
16
|
+
import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout } from './layout.js'
|
|
17
17
|
|
|
18
18
|
// this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
|
|
19
19
|
// materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
|
|
@@ -219,6 +219,21 @@ async function doctor(): Promise<number> {
|
|
|
219
219
|
line('spex-adopted', adopted ? 'yes (.spec/ + spexcode.json)' : root ? 'no — run `spex init`' : 'not a git repo')
|
|
220
220
|
line('root', base)
|
|
221
221
|
line('mode', managed ? 'managed worktree (backend-launched session)' : 'standalone repo (bring-your-own-agent)')
|
|
222
|
+
// the issues-workflow switch ([[local-issues]]): its only home is the `issues.enabled` settings key
|
|
223
|
+
// (v0.3.0 — the on|off|status CLI verbs are gone), so doctor is where its state is READ. A legacy
|
|
224
|
+
// pre-rename `proposals.enabled` key is no longer consulted at runtime (no fallback) — flag it here so
|
|
225
|
+
// an old settings file gets repaired instead of silently drifting from what its author believes.
|
|
226
|
+
if (adopted) {
|
|
227
|
+
const { issuesEnabled } = await import('./localIssues.js')
|
|
228
|
+
// probe the SAME files the switch actually reads — the trunk checkout's settings pair, not this
|
|
229
|
+
// worktree's (readConfig resolves to mainCheckout, so a legacy key only matters there).
|
|
230
|
+
const cfgHome = (() => { try { return mainCheckout() } catch { return base } })()
|
|
231
|
+
const legacy = ['spexcode.json', 'spexcode.local.json'].filter((f) => {
|
|
232
|
+
try { return 'proposals' in JSON.parse(readFileSync(join(cfgHome, f), 'utf8')) } catch { return false }
|
|
233
|
+
})
|
|
234
|
+
line('issues workflow', `${issuesEnabled() ? 'ON' : 'OFF'} (spexcode.json issues.enabled)`)
|
|
235
|
+
if (legacy.length) line(' LEGACY key', `\`proposals\` found in ${legacy.map((f) => join(cfgHome, f)).join(', ')} — no longer read; rename it to "issues": { "enabled": … }`)
|
|
236
|
+
}
|
|
222
237
|
|
|
223
238
|
// --- preconditions: nothing downstream fires without these ---
|
|
224
239
|
L.push('\nPreconditions (without these nothing downstream fires)')
|
|
@@ -247,7 +262,7 @@ async function doctor(): Promise<number> {
|
|
|
247
262
|
const present = h.contractFiles(base).every((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
|
|
248
263
|
line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex doctor contract` / materialize')
|
|
249
264
|
}
|
|
250
|
-
line('view', 'spex doctor contract')
|
|
265
|
+
line('view', 'spex doctor --contract')
|
|
251
266
|
|
|
252
267
|
// --- hooks: the shim → dispatch, the manifest, and EVERY handler readable in the worktree ---
|
|
253
268
|
L.push('\nLayer 3 — hooks (shim → dispatch · manifest · handler-existence)')
|
|
@@ -293,7 +308,7 @@ async function doctor(): Promise<number> {
|
|
|
293
308
|
line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
|
|
294
309
|
line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
|
|
295
310
|
line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
|
|
296
|
-
line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor conflicts`)' : 'clean (single channel)')
|
|
311
|
+
line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor --conflicts`)' : 'clean (single channel)')
|
|
297
312
|
|
|
298
313
|
// --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
|
|
299
314
|
L.push('\nFootprint (what Spex wrote into this environment)')
|
|
@@ -323,12 +338,12 @@ function contract(): number {
|
|
|
323
338
|
}
|
|
324
339
|
|
|
325
340
|
// the focused double-delivery check: JUST Layer 5, exit non-zero when a conflict is live so it gates a script
|
|
326
|
-
// /
|
|
341
|
+
// / eval. Anchors at the repo root like doctor (the shims + contract + skills live there).
|
|
327
342
|
async function conflicts(): Promise<number> {
|
|
328
343
|
const cwd = process.cwd()
|
|
329
344
|
const base = repoRoot(cwd) ?? cwd
|
|
330
345
|
const { lines, conflict } = await doubleDeliveryReport(base)
|
|
331
|
-
console.log(['spex doctor conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
|
|
346
|
+
console.log(['spex doctor --conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
|
|
332
347
|
return conflict ? 1 : 0
|
|
333
348
|
}
|
|
334
349
|
|
|
@@ -338,26 +353,38 @@ async function conflicts(): Promise<number> {
|
|
|
338
353
|
function noteStaged(verb: string): number {
|
|
339
354
|
console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
|
|
340
355
|
(the live hooks must detect a missing managed session and degrade before they can be safely wired into your
|
|
341
|
-
own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor contract\` prints
|
|
356
|
+
own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor --contract\` prints
|
|
342
357
|
the workflow text you can hand any agent.`)
|
|
343
358
|
return 2
|
|
344
359
|
}
|
|
345
360
|
|
|
346
361
|
function usage(): number {
|
|
347
362
|
console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
|
|
348
|
-
(bare)
|
|
349
|
-
contract print the surface:system contract text (hand it to any agent)
|
|
350
|
-
conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
|
|
351
|
-
|
|
352
|
-
|
|
363
|
+
(bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
|
|
364
|
+
--contract print the surface:system contract text (hand it to any agent)
|
|
365
|
+
--conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
|
|
366
|
+
--migrate one-shot 0.2.x → 0.3.0 vocabulary migration for an adopter repo (renames + hook-asset
|
|
367
|
+
upgrade + body rewrites; refuses on a dirty tree / undrained sessions / an already-migrated
|
|
368
|
+
tree; stages everything, commits nothing; removed in 0.4.0)
|
|
369
|
+
install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
|
|
370
|
+
uninstall [staged] reverse exactly what install wrote`)
|
|
353
371
|
return 0
|
|
354
372
|
}
|
|
355
373
|
|
|
356
374
|
export async function runDoctor(args: string[]): Promise<number> {
|
|
375
|
+
// contract/conflicts are FLAGS, not subcommands ([[cli-surface]] §4: another representation of the same
|
|
376
|
+
// diagnosis read, not a distinct action). The old positional spellings signpost — report, never run.
|
|
377
|
+
// --migrate is the one MUTATING flag: the term-limited 0.2.x→0.3.0 adopter migrator ([[migrate]],
|
|
378
|
+
// ships with 0.3.0, deleted in 0.4.0) — dispatched first so its refusal/summary is never diluted
|
|
379
|
+
// by the diagnosis report.
|
|
380
|
+
if (args.includes('--migrate')) return await (await import('./migrate.js')).runMigrate()
|
|
381
|
+
if (args.includes('--contract')) return contract()
|
|
382
|
+
if (args.includes('--conflicts')) return await conflicts()
|
|
357
383
|
switch (args[0]) {
|
|
358
384
|
case undefined: return await doctor()
|
|
359
|
-
case 'contract':
|
|
360
|
-
|
|
385
|
+
case 'contract': case 'conflicts':
|
|
386
|
+
console.error(`spex: \`spex doctor ${args[0]}\` was removed in v0.3.0 — use: spex doctor --${args[0]}`)
|
|
387
|
+
return 2
|
|
361
388
|
case 'install': return noteStaged('install')
|
|
362
389
|
case 'uninstall': return noteStaged('uninstall')
|
|
363
390
|
case 'help': case '--help': case '-h': return usage()
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { homedir } from 'node:os'
|
|
16
16
|
import { loginPage } from './login-page.js'
|
|
17
17
|
import { listenOrExit } from './listen.js'
|
|
18
|
+
import { installConnectionReaper } from './reaper.js'
|
|
18
19
|
|
|
19
20
|
// @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
|
|
20
21
|
// chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
|
|
@@ -150,14 +151,17 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
// server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
|
|
153
|
-
// public server in public mode, so it carries the SAME reaping as the child
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
154
|
+
// public server in public mode, so it carries the SAME reaping as the child. The `reap` HTTP timeouts are
|
|
155
|
+
// kept (harmless) but are NOT the mechanism — MEASURED (eval server-reaps-abandoned-connections), Node's
|
|
156
|
+
// headersTimeout/requestTimeout don't reap an INCOMPLETE request; the real reaper is the socket-level
|
|
157
|
+
// `installConnectionReaper` (reaper.ts), applied below at every http server the serving path creates.
|
|
158
|
+
// Idle keep-alive / slow-loris / never-completing request only; the gated WS upgrade (handled below) is an
|
|
159
|
+
// active stream and exempt for its lifetime.
|
|
157
160
|
const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
|
|
158
161
|
const server = secure
|
|
159
162
|
? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
|
|
160
163
|
: http.createServer(reap, handler)
|
|
164
|
+
installConnectionReaper(server)
|
|
161
165
|
|
|
162
166
|
// @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
|
|
163
167
|
// it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
|
|
@@ -173,7 +177,7 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
173
177
|
socket.on('error', bail); up.on('error', bail)
|
|
174
178
|
})
|
|
175
179
|
|
|
176
|
-
// `spex
|
|
180
|
+
// `spex serve ui` passes an explicit host (loopback by default, --host to widen); `--public` passes
|
|
177
181
|
// none → bind ALL interfaces (the original behaviour, IPv4+IPv6), so adding the local path never
|
|
178
182
|
// narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
|
|
179
183
|
// an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
|
|
@@ -186,7 +190,7 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
186
190
|
if (!secure && !isLoopback && !opts.host) console.log('[gateway] (TLS off — --http)')
|
|
187
191
|
}
|
|
188
192
|
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
189
|
-
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex
|
|
193
|
+
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
|
|
190
194
|
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -271,7 +275,7 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
|
|
|
271
275
|
}
|
|
272
276
|
}
|
|
273
277
|
|
|
274
|
-
// @@@ serveDashboardLocal - the engine behind `spex
|
|
278
|
+
// @@@ serveDashboardLocal - the engine behind `spex serve ui`: the SAME gateway as public mode, bound to
|
|
275
279
|
// loopback by default with no TLS and no password — `--host` widens the bind to a chosen interface
|
|
276
280
|
// (LAN/tailnet viewing) while staying plain HTTP; the internet face remains `spex serve --public`.
|
|
277
281
|
// It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join, isAbsolute, resolve } from 'node:path'
|
|
|
6
6
|
const US = '\x1f', RS = '\x1e'
|
|
7
7
|
|
|
8
8
|
// @@@ bounded git children - a git child that never exits (wedged fs, a hijacked PATH git, a dead network
|
|
9
|
-
// mount) must not pin its awaiter forever: [[
|
|
9
|
+
// mount) must not pin its awaiter forever: [[graph-cache]]'s settle guarantee starts at this seam. Every
|
|
10
10
|
// shared helper passes a generous timeout (an order of magnitude above the slowest legitimate full-history
|
|
11
11
|
// walk) with SIGKILL — same pattern sessions.ts's tmux/ps probes already use — so a hung child dies and the
|
|
12
12
|
// call fails like any other git failure instead of hanging its caller's promise. The kill is warned loudly:
|
|
@@ -154,7 +154,7 @@ function parseStatPath(token: string): { from: string; to: string } {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
|
|
157
|
-
// once — the backend checkout (board, loadSpecs) plus every session worktree ([[
|
|
157
|
+
// once — the backend checkout (board, loadSpecs) plus every session worktree ([[session-eval]]'s eval
|
|
158
158
|
// tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
|
|
159
159
|
// each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
|
|
160
160
|
// `git log` and re-parses it on the event loop — which is what starves every other request (the board,
|
|
@@ -6,8 +6,8 @@ import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
|
6
6
|
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
7
7
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
8
8
|
import { mergedIssues } from './issues.js'
|
|
9
|
-
import { evalContext, evalTimeline } from '../../spec-
|
|
10
|
-
import {
|
|
9
|
+
import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
10
|
+
import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
|
|
11
11
|
|
|
12
12
|
// a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
|
|
13
13
|
function resolveParent(path: string, byDir: Record<string, string>): string | null {
|
|
@@ -20,7 +20,7 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
|
|
|
20
20
|
return null
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// the board's eval summary ([[
|
|
23
|
+
// the board's eval summary ([[graph-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
|
|
24
24
|
// reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
|
|
25
25
|
// annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
|
|
26
26
|
// degradation no error would surface; the field-preservation unit test pins this contract.
|
|
@@ -29,7 +29,7 @@ export function latestPerScenario<T extends { scenario: string }>(readings: T[])
|
|
|
29
29
|
return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
// the board's scenario fold ([[
|
|
32
|
+
// the board's scenario fold ([[graph-lean]]): the declared set rides SLIM — {name, tags} is everything an
|
|
33
33
|
// overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
|
|
34
34
|
// expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
|
|
35
35
|
export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
|
|
@@ -44,7 +44,7 @@ export async function buildBoard() {
|
|
|
44
44
|
const root = repoRoot()
|
|
45
45
|
const [layout, sessions, specs] = await Promise.all([resolveLayout(), listSessions(), loadSpecs()])
|
|
46
46
|
// the eval fold's freshness axes: WARM hits — loadSpecs already computed this HEAD's drift + history
|
|
47
|
-
// indices, so these are the same cached walks, fetched once and reused for every
|
|
47
|
+
// indices, so these are the same cached walks, fetched once and reused for every measurable node (the history
|
|
48
48
|
// index drives the rename-safe scenario axis, mirroring a spec node's own freshness).
|
|
49
49
|
const idx = await driftIndex(root)
|
|
50
50
|
const hidx = await historyIndex(root)
|
|
@@ -96,7 +96,7 @@ export async function buildBoard() {
|
|
|
96
96
|
const nodes = [
|
|
97
97
|
...specs.map((n: any) => {
|
|
98
98
|
const overlays = overlaysByNode[n.id] || []
|
|
99
|
-
// `body` and its derivation `parts` are DROPPED from the board payload ([[
|
|
99
|
+
// `body` and its derivation `parts` are DROPPED from the board payload ([[graph-lean]]): together ~56% of
|
|
100
100
|
// the bytes, and detail the graph overview never renders. The detail view fetches them per node from
|
|
101
101
|
// `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
|
|
102
102
|
// once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
|
|
@@ -109,8 +109,21 @@ export async function buildBoard() {
|
|
|
109
109
|
// openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
|
|
110
110
|
// empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
|
|
111
111
|
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
112
|
+
const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
|
|
113
|
+
// ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
|
|
114
|
+
// open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
|
|
115
|
+
// resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes:
|
|
116
|
+
// [[graph-delta]] suppresses no-change broadcasts, and without a moving byte an external write would
|
|
117
|
+
// stay invisible to viewers until the fallback poll ([[remark-substrate]] write-visibility). The per-node
|
|
118
|
+
// fold below stays [[graph-lean]]-slim (no reply payloads); this stamp is the freshness carrier.
|
|
119
|
+
const issuesStamp = [
|
|
120
|
+
merged.filter(isOpen).length,
|
|
121
|
+
merged.length,
|
|
122
|
+
merged.reduce((n, i) => n + i.replies.length, 0),
|
|
123
|
+
merged.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
|
|
124
|
+
].join(':')
|
|
112
125
|
const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
|
|
113
|
-
for (const issue of
|
|
126
|
+
for (const issue of merged)
|
|
114
127
|
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
115
128
|
for (const n of nodes) {
|
|
116
129
|
const issues = issuesByNode[n.id]
|
|
@@ -122,20 +135,20 @@ export async function buildBoard() {
|
|
|
122
135
|
if (open.length) n.openIssues = open
|
|
123
136
|
}
|
|
124
137
|
|
|
125
|
-
// fold each
|
|
138
|
+
// fold each measurable node's eval state onto it — as the LEAN summary ([[graph-lean]]): `evals` carries only
|
|
126
139
|
// the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
|
|
127
140
|
// badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
|
|
128
141
|
// and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
|
|
129
142
|
// SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
|
|
130
143
|
// (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
|
|
131
144
|
// (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
|
|
132
|
-
// evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-
|
|
133
|
-
//
|
|
134
|
-
const ynodes = await
|
|
145
|
+
// evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
|
|
146
|
+
// eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
|
|
147
|
+
const ynodes = await evalNodesAsync(root)
|
|
135
148
|
const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
|
|
136
149
|
await Promise.all(nodes.map(async (n) => {
|
|
137
150
|
const tl = await evalTimeline(n.id, ectx)
|
|
138
|
-
if (tl.
|
|
151
|
+
if (tl.hasEvalFile) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
|
|
139
152
|
}))
|
|
140
153
|
|
|
141
154
|
const opsByPath: Record<string, any[]> = {}
|
|
@@ -144,6 +157,23 @@ export async function buildBoard() {
|
|
|
144
157
|
|
|
145
158
|
const dash = readConfig(root).dashboard
|
|
146
159
|
// project names the tab ([[tab-title]]); projectIcon is the tab favicon ([[tab-icon]]) — both ride the
|
|
147
|
-
// /api/
|
|
148
|
-
return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '' }
|
|
160
|
+
// /api/graph poll so they re-derive from whichever backend the viewer reached. Empty icon → frontend default.
|
|
161
|
+
return { nodes, sessions: sess, project: dash?.title || basename(root), projectIcon: dash?.icon || '', issuesStamp }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
|
|
165
|
+
// write, a liveness/activity poll flip) reshapes only the board's `sessions` rows — the node/meta units are
|
|
166
|
+
// untouched — so the cache re-derives ONLY the sessions and splices them onto the previous board verbatim,
|
|
167
|
+
// skipping the whole loadSpecs/layout/eval assembly a full buildBoard() pays. Pure aside from listSessions:
|
|
168
|
+
// each row is decorated EXACTLY as buildBoard's sess mapping (`{...s, source: s.path, ops}`), and every
|
|
169
|
+
// path's `ops` is REUSED from the previous board (a path→ops map). A session path absent in `prev` gets []
|
|
170
|
+
// — a brand-new worktree has no pending spec ops yet, and any later ops-CHANGING event (a commit, a
|
|
171
|
+
// worktree `.spec` edit) is refs/worktree-scoped, i.e. a FULL rebuild, never a sessions splice. So the
|
|
172
|
+
// splice is byte-indistinguishable from a full rebuild whenever only session state moved.
|
|
173
|
+
export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
|
|
174
|
+
const sessions = await listSessions()
|
|
175
|
+
const opsByPath: Record<string, any[]> = {}
|
|
176
|
+
for (const s of prev.sessions) opsByPath[s.source] = s.ops
|
|
177
|
+
const sess = sessions.map((s) => ({ ...s, source: s.path, ops: opsByPath[s.path] || [] }))
|
|
178
|
+
return { ...prev, sessions: sess }
|
|
149
179
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { buildBoard } from './
|
|
1
|
+
import { buildBoard, spliceSessions } from './graph.js'
|
|
2
2
|
|
|
3
|
-
// @@@
|
|
3
|
+
// @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
|
|
4
4
|
// board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
|
|
5
5
|
// route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
|
|
6
6
|
// dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
|
|
7
7
|
// starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
|
|
8
8
|
// concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
|
|
9
9
|
// result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
|
|
10
|
-
// signals [[
|
|
10
|
+
// signals [[graph-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
|
|
11
11
|
// invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
|
|
12
12
|
// the route share the very same in-flight build.
|
|
13
13
|
|
|
@@ -22,7 +22,7 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
|
22
22
|
// below, so a never-settling buildBoard() would pin the single-flight forever — every later read (even of a
|
|
23
23
|
// perfectly good cached board) short-circuits into the pinned promise before `valid` is consulted,
|
|
24
24
|
// invalidation can't help, no log ever fires, and only a restart cures it (the live wedge: hung git
|
|
25
|
-
// children → /api/
|
|
25
|
+
// children → /api/graph 503 forever, silently). So the build races a generous watchdog that REJECTS loudly;
|
|
26
26
|
// the rejection flows through the SAME finally → inflight clears → the next read retries fresh. Sitting at
|
|
27
27
|
// the single-flight boundary, this one wall bounds every never-settle cause — including ones with no child
|
|
28
28
|
// process at all (fs/promises under libuv threadpool starvation); git.ts's per-child timeouts merely make
|
|
@@ -30,53 +30,73 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
|
30
30
|
// fires on a genuine wedge.
|
|
31
31
|
const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// the cache's staleness has a DOMAIN, not just a bit: a 'sessions' change (a lifecycle write, a
|
|
34
|
+
// liveness/activity poll flip) touches only the session rows, so the next read can SPLICE fresh sessions
|
|
35
|
+
// onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
|
|
36
|
+
// worktree `.spec` edit, the cold-tick patrol) can reshape anything, so the next read does the whole
|
|
37
|
+
// buildBoard(). 'none' = clean.
|
|
38
|
+
type Scope = 'sessions' | 'full'
|
|
39
|
+
let cached: Board | null = null // last completed build; served while `dirty === 'none'`
|
|
34
40
|
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
35
|
-
let
|
|
41
|
+
let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
|
|
36
42
|
let inflight: Promise<Board> | null = null
|
|
37
43
|
let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
|
|
38
44
|
|
|
39
|
-
// mark the cache stale. Called by every board-stream freshness source (see
|
|
40
|
-
// real change forces the next getBoard() to rebuild while a quiet poll storm
|
|
41
|
-
|
|
45
|
+
// mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
|
|
46
|
+
// boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
|
|
47
|
+
// keeps hitting the cache. The scope only ESCALATES within a dirty window: none→sessions→full, and a
|
|
48
|
+
// 'sessions' signal arriving while 'full' is already pending stays 'full' (a full rebuild subsumes a
|
|
49
|
+
// sessions splice). cachedJson is dropped either way — a splice replaces the board object, so its old
|
|
50
|
+
// serialization is stale regardless of scope.
|
|
51
|
+
export function invalidateBoard(scope: Scope = 'full'): void {
|
|
42
52
|
gen++
|
|
43
|
-
|
|
53
|
+
if (scope === 'full' || dirty === 'full') dirty = 'full'
|
|
54
|
+
else dirty = 'sessions'
|
|
55
|
+
cachedJson = null
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
// the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
|
|
47
59
|
// build shares the in-flight promise; a caller after a completed build gets the cached value until the
|
|
48
|
-
// next invalidation. A
|
|
49
|
-
//
|
|
50
|
-
//
|
|
60
|
+
// next invalidation. A 'sessions'-scoped dirty with a cached board takes the SPLICE path (spliceSessions —
|
|
61
|
+
// fresh session rows onto the cached node/meta units) under the SAME single-flight promise + watchdog +
|
|
62
|
+
// generation rules; anything else (dirty 'full', or no cache to splice onto) does a full buildBoard(). A
|
|
63
|
+
// change that lands WHILE a build runs (gen moved) leaves the cache dirty so the NEXT read rebuilds — a
|
|
64
|
+
// 'full' invalidation landing mid-splice leaves it dirty 'full' for the next read. The just-finished build
|
|
65
|
+
// still returns to its waiters (freshest available when they asked), never cached as current. Mirrors
|
|
66
|
+
// [[graph-stream]]'s building/dirty loop.
|
|
51
67
|
export function getBoard(): Promise<Board> {
|
|
52
68
|
if (inflight) return inflight
|
|
53
|
-
if (
|
|
69
|
+
if (dirty === 'none' && cached) return Promise.resolve(cached)
|
|
54
70
|
const startGen = gen
|
|
71
|
+
const sessionsOnly = dirty === 'sessions' && cached !== null
|
|
72
|
+
const prev = cached
|
|
55
73
|
const p = (async () => {
|
|
56
74
|
const t0 = Date.now()
|
|
57
75
|
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
58
76
|
try {
|
|
59
77
|
const board = await Promise.race([
|
|
60
|
-
buildBoard(),
|
|
78
|
+
sessionsOnly ? spliceSessions(prev!) : buildBoard(),
|
|
61
79
|
// the race consumes the loser's eventual settlement, so an abandoned build that fails later
|
|
62
80
|
// can't surface as an unhandled rejection; unref'd so a pending watchdog never holds a one-shot
|
|
63
81
|
// CLI process open.
|
|
64
82
|
new Promise<never>((_, reject) => {
|
|
65
83
|
watchdog = setTimeout(() => {
|
|
66
|
-
console.warn(`spec-cli: /api/
|
|
67
|
-
reject(new Error(`
|
|
84
|
+
console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — wedged build abandoned so the next read can retry`)
|
|
85
|
+
reject(new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`))
|
|
68
86
|
}, BUILD_TIMEOUT_MS)
|
|
69
87
|
watchdog.unref?.()
|
|
70
88
|
}),
|
|
71
89
|
])
|
|
72
90
|
cached = board
|
|
73
91
|
cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
|
|
74
|
-
|
|
92
|
+
// clean ONLY if no invalidation landed during the build; otherwise leave `dirty` at whatever scope
|
|
93
|
+
// those in-build invalidations escalated it to (a mid-splice 'full' stays 'full' for the next read).
|
|
94
|
+
if (gen === startGen) dirty = 'none'
|
|
75
95
|
return board
|
|
76
96
|
} finally {
|
|
77
97
|
clearTimeout(watchdog)
|
|
78
98
|
const ms = Date.now() - t0
|
|
79
|
-
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/
|
|
99
|
+
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
|
|
80
100
|
inflight = null
|
|
81
101
|
}
|
|
82
102
|
})()
|
|
@@ -84,9 +104,9 @@ export function getBoard(): Promise<Board> {
|
|
|
84
104
|
return p
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
// the SERIALIZED board for the /api/
|
|
107
|
+
// the SERIALIZED board for the /api/graph route — JSON.stringify runs ONCE per build, not once per poll,
|
|
88
108
|
// so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
|
|
89
|
-
// path still takes the object (getBoard) because it decomposes it into delta units ([[
|
|
109
|
+
// path still takes the object (getBoard) because it decomposes it into delta units ([[graph-delta]]).
|
|
90
110
|
export async function getBoardJson(): Promise<string> {
|
|
91
111
|
const board = await getBoard()
|
|
92
112
|
if (board === cached && cachedJson !== null) return cachedJson
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
|
|
3
3
|
// @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
|
|
4
|
-
// UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[
|
|
4
|
+
// UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[graph-stream]])
|
|
5
5
|
// chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
|
|
6
6
|
// instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
|
|
7
7
|
// dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
|