spexcode 0.5.1 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
package/spec-cli/src/graph.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { listSessions } from './sessions.js'
|
|
|
4
4
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
5
5
|
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
6
6
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
7
|
-
import {
|
|
7
|
+
import { boardThreads } from './issues.js'
|
|
8
8
|
import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
9
9
|
import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
|
|
10
10
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
@@ -42,6 +42,14 @@ export function nodeEvalSummary(scenarios: { name: string }[], readings: any[])
|
|
|
42
42
|
return summary
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// @@@ a shelved row carries no delta ([[archive]]) - the board has TWO producers (a full buildBoard and the
|
|
46
|
+
// sessions-only spliceSessions), and shelving is a session-scoped write, so it takes the SPLICE path — which
|
|
47
|
+
// reuses the previous board's ops by path. Skipping the delta only in layout's row builder would therefore be
|
|
48
|
+
// invisible exactly when it fires: the splice would carry the stale ops forward forever. The rule belongs to
|
|
49
|
+
// the row, not to one producer, so both decorate through this one function.
|
|
50
|
+
const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<string, any[]>): any[] =>
|
|
51
|
+
(s.archived ? [] : opsByPath[s.path] || [])
|
|
52
|
+
|
|
45
53
|
export async function buildBoard() {
|
|
46
54
|
// all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
|
|
47
55
|
// REUSES the HEAD-keyed spec-history cache (the git-derived node data — see specs.ts/git.ts), resolveLayout
|
|
@@ -113,20 +121,16 @@ export async function buildBoard() {
|
|
|
113
121
|
// Reconcile Issues once. Full rows stay in the server-only review snapshot; graph nodes get counts and
|
|
114
122
|
// open identity only, enough for tile/stat/tree glances without reconstructing the list.
|
|
115
123
|
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
merged.reduce((n, i) => n + i.replies.length, 0),
|
|
127
|
-
merged.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
|
|
128
|
-
].join(':')
|
|
129
|
-
const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
|
|
124
|
+
// ONE store walk yielding both halves ([[issues]] boardThreads): the ISSUE surfaces get the split
|
|
125
|
+
// population, the freshness carrier gets the whole store.
|
|
126
|
+
const { issues: merged, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
|
|
127
|
+
// `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread — noded or nodeless,
|
|
128
|
+
// both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
|
|
129
|
+
// scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
|
|
130
|
+
// carrier folded over the issue half alone left an open READING blind to every remark on it — the write
|
|
131
|
+
// moved no board byte, [[graph-delta]] correctly suppressed the no-change broadcast, and the push never
|
|
132
|
+
// fired at all. The per-node fold below stays [[graph-lean]]-slim (no reply payloads).
|
|
133
|
+
const issuesByNode: Record<string, ReturnType<typeof boardThreads>['issues']> = {}
|
|
130
134
|
for (const issue of merged)
|
|
131
135
|
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
132
136
|
for (const n of nodes) {
|
|
@@ -161,7 +165,7 @@ export async function buildBoard() {
|
|
|
161
165
|
const sess = sessions.map((s) => ({
|
|
162
166
|
...s,
|
|
163
167
|
source: s.path,
|
|
164
|
-
ops:
|
|
168
|
+
ops: rowOps(s, opsByPath),
|
|
165
169
|
evalSummary: evalProjections.get(s.id),
|
|
166
170
|
}))
|
|
167
171
|
|
|
@@ -189,7 +193,7 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
|
|
|
189
193
|
const sess = sessions.map((s) => ({
|
|
190
194
|
...s,
|
|
191
195
|
source: s.path,
|
|
192
|
-
ops:
|
|
196
|
+
ops: rowOps(s, opsByPath),
|
|
193
197
|
evalSummary: evalProjections.get(s.id),
|
|
194
198
|
}))
|
|
195
199
|
return { ...prev, sessions: sess }
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
1
4
|
import { buildBoard, spliceSessions } from './graph.js'
|
|
5
|
+
import { headSha, repoRoot, withGitAbortSignal } from './git.js'
|
|
6
|
+
import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from './layout.js'
|
|
7
|
+
import { boardThreads } from './issues.js'
|
|
8
|
+
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
9
|
+
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
10
|
+
import { resolveProjectIdentity } from './project-identity.js'
|
|
11
|
+
import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
|
|
2
12
|
|
|
3
13
|
// @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
|
|
4
14
|
// board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
|
|
@@ -7,11 +17,197 @@ import { buildBoard, spliceSessions } from './graph.js'
|
|
|
7
17
|
// starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
|
|
8
18
|
// concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
|
|
9
19
|
// result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
|
|
10
|
-
// signals [[graph-stream]] already watches (session-store writes, git-ref moves
|
|
11
|
-
//
|
|
12
|
-
// the route share the
|
|
20
|
+
// signals [[graph-stream]] already watches (session-store writes, git-ref/worktree moves); the cold patrol
|
|
21
|
+
// enters this cache's input-revision validation flight instead of manufacturing a change. So a poll storm
|
|
22
|
+
// costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and the route share the same operation.
|
|
13
23
|
|
|
14
24
|
export type Board = Awaited<ReturnType<typeof buildBoard>>
|
|
25
|
+
export type BoardConsistency = 'fresh' | 'stale-ok'
|
|
26
|
+
export type BoardRead = { board: Board; freshness: 'fresh' | 'stale'; refreshing: boolean; error?: string }
|
|
27
|
+
export type BoardJsonRead = BoardRead & { json: string }
|
|
28
|
+
|
|
29
|
+
type BoardInputRevision = {
|
|
30
|
+
full: string
|
|
31
|
+
sessions: string
|
|
32
|
+
projections: string
|
|
33
|
+
combined: string
|
|
34
|
+
fullParts: Record<string, string>
|
|
35
|
+
projectionIds: string[]
|
|
36
|
+
}
|
|
37
|
+
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
38
|
+
|
|
39
|
+
function textOrNull(path: string): string | null {
|
|
40
|
+
try { return readFileSync(path, 'utf8') }
|
|
41
|
+
catch (error) {
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function gitDirOf(root: string): string {
|
|
48
|
+
const dotgit = join(root, '.git')
|
|
49
|
+
try { if (statSync(dotgit).isDirectory()) return dotgit }
|
|
50
|
+
catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
const dotgitText = textOrNull(dotgit)
|
|
55
|
+
if (dotgitText === null) return ''
|
|
56
|
+
const match = dotgitText.match(/^gitdir:\s*(.+)$/m)
|
|
57
|
+
if (!match) throw new Error(`unparseable gitdir pointer at ${dotgit}`)
|
|
58
|
+
const dir = match[1].trim()
|
|
59
|
+
return isAbsolute(dir) ? dir : resolve(root, dir)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commonDirOf(root: string): string {
|
|
63
|
+
const gitDir = gitDirOf(root)
|
|
64
|
+
if (!gitDir) return ''
|
|
65
|
+
const common = textOrNull(join(gitDir, 'commondir'))
|
|
66
|
+
if (common === null) return gitDir
|
|
67
|
+
const dir = common.trim()
|
|
68
|
+
if (!dir) throw new Error(`empty commondir pointer at ${join(gitDir, 'commondir')}`)
|
|
69
|
+
return isAbsolute(dir) ? dir : resolve(gitDir, dir)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function refSha(root: string, ref: string): string {
|
|
73
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return ref
|
|
74
|
+
const common = commonDirOf(root)
|
|
75
|
+
if (!common) return '<missing>'
|
|
76
|
+
const names = ref.startsWith('refs/') ? [ref] : [`refs/heads/${ref}`, `refs/remotes/${ref}`]
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const loose = textOrNull(join(common, name))
|
|
79
|
+
if (loose !== null) return loose.trim() || '<missing>'
|
|
80
|
+
}
|
|
81
|
+
const packed = textOrNull(join(common, 'packed-refs'))
|
|
82
|
+
if (packed !== null) {
|
|
83
|
+
for (const line of packed.split('\n')) {
|
|
84
|
+
if (!line || line[0] === '#' || line[0] === '^') continue
|
|
85
|
+
const space = line.indexOf(' ')
|
|
86
|
+
if (space > 0 && names.includes(line.slice(space + 1).trim())) return line.slice(0, space).trim()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return '<missing>'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function headRevision(root: string): string {
|
|
93
|
+
if (!gitDirOf(root)) return '<missing>'
|
|
94
|
+
return headSha(root)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Patrol validation needs a stronger contract than the overlay cache's best-effort mtime key: ctime catches
|
|
98
|
+
// a same-size edit whose mtime was restored, and every non-ENOENT read failure is loud instead of collapsing an
|
|
99
|
+
// unreadable subtree into a stable "unchanged" revision. A path that vanishes during the sample is represented
|
|
100
|
+
// by its absence and is rechecked by the producer fence.
|
|
101
|
+
function strictSpecTreeRevision(wtPath: string): string {
|
|
102
|
+
const root = join(wtPath, '.spec')
|
|
103
|
+
try { statSync(root) }
|
|
104
|
+
catch (error) {
|
|
105
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
const parts: string[] = []
|
|
109
|
+
const stack = [root]
|
|
110
|
+
while (stack.length) {
|
|
111
|
+
const dir = stack.pop()!
|
|
112
|
+
let entries
|
|
113
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) }
|
|
114
|
+
catch (error) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const path = join(dir, entry.name)
|
|
120
|
+
if (entry.isDirectory()) { stack.push(path); continue }
|
|
121
|
+
try {
|
|
122
|
+
const stat = statSync(path)
|
|
123
|
+
parts.push(`${path}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parts.sort().join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function worktreeRevision(root: string): unknown {
|
|
133
|
+
return {
|
|
134
|
+
root,
|
|
135
|
+
head: headRevision(root),
|
|
136
|
+
spec: strictSpecTreeRevision(root),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function digest(value: unknown): string {
|
|
141
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The patrol's cheap authority check. These are exactly the mutable inputs a board assembly reads, folded
|
|
145
|
+
// without doing the assembly: HEAD/config + `.spec`, governed records and active governed worktree state,
|
|
146
|
+
// the issue-store carrier, and the already-resident session-eval projections. It intentionally does not call
|
|
147
|
+
// listSessions or sessionEvalProjections: verification must neither poll tmux again nor mint/schedule eval work.
|
|
148
|
+
// The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
|
|
149
|
+
function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
150
|
+
const root = repoRoot()
|
|
151
|
+
const ids = listSessionIds().sort()
|
|
152
|
+
// listSessions projects both the structured record and the separately-stored originating prompt into each
|
|
153
|
+
// board row. Fold both exact artifacts so a missed store event cannot leave a stale label/prompt forever.
|
|
154
|
+
const sessionInputs = ids.map((id) => [
|
|
155
|
+
id,
|
|
156
|
+
textOrNull(sessionRecordPath(id)),
|
|
157
|
+
textOrNull(sessionArtifactPath(id, 'prompt')),
|
|
158
|
+
] as const)
|
|
159
|
+
const records = ids.map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
|
|
160
|
+
const governed = records.filter((record) => record.governed).sort((a, b) => a.session_id.localeCompare(b.session_id))
|
|
161
|
+
const activeRoots = [...new Set(governed.filter((record) => !record.archived).map((record) => record.worktree_path))].sort()
|
|
162
|
+
const main = mainCheckout()
|
|
163
|
+
const base = mainBranch()
|
|
164
|
+
const mainTip = refSha(main, base)
|
|
165
|
+
|
|
166
|
+
const nodeIds = (board?.nodes ?? []).map((node) => node.id)
|
|
167
|
+
const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
|
|
168
|
+
const fullInputs = {
|
|
169
|
+
root: worktreeRevision(root),
|
|
170
|
+
config: [
|
|
171
|
+
[join(root, 'spexcode.json'), textOrNull(join(root, 'spexcode.json'))],
|
|
172
|
+
[join(root, 'spexcode.local.json'), textOrNull(join(root, 'spexcode.local.json'))],
|
|
173
|
+
[join(main, 'spexcode.json'), textOrNull(join(main, 'spexcode.json'))],
|
|
174
|
+
[join(main, 'spexcode.local.json'), textOrNull(join(main, 'spexcode.local.json'))],
|
|
175
|
+
],
|
|
176
|
+
main: { root: main, branch: base, tip: mainTip },
|
|
177
|
+
worktrees: activeRoots.map((worktree) => worktreeRevision(worktree)),
|
|
178
|
+
issuesStamp,
|
|
179
|
+
identity: resolveProjectIdentity(root, root),
|
|
180
|
+
}
|
|
181
|
+
const fullParts = Object.fromEntries(Object.entries(fullInputs).map(([key, value]) => [key, digest(value)]))
|
|
182
|
+
const full = digest(fullParts)
|
|
183
|
+
const sessions = digest(sessionInputs)
|
|
184
|
+
|
|
185
|
+
// Every board row carries an evalSummary, including non-governed sessions. Governed filtering owns only
|
|
186
|
+
// worktree observation; projection identity follows the whole store enumeration so a blind observer cannot
|
|
187
|
+
// strand a non-governed row on an older resident phase/value.
|
|
188
|
+
const projectionIds = ids
|
|
189
|
+
const projections = digest(projectionIds.map((id) => [id, sessionEvalProjection(id)]))
|
|
190
|
+
return { full, sessions, projections, combined: digest([full, sessions, projections]), fullParts, projectionIds }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Bind an input sample to what the completed board actually carries. The sample supplies the graph/session
|
|
194
|
+
// revision observed immediately after the producer; issue and projection values come from the returned board
|
|
195
|
+
// itself. Re-reading all inputs here would create a certification race: a blind change after the producer's
|
|
196
|
+
// after-sample could otherwise be mistaken for an input the already-built board contains.
|
|
197
|
+
function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): BoardInputRevision {
|
|
198
|
+
const fullParts = { ...sample.fullParts, issuesStamp: digest(board.issuesStamp) }
|
|
199
|
+
const full = digest(fullParts)
|
|
200
|
+
const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
|
|
201
|
+
const projections = digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
|
|
202
|
+
return {
|
|
203
|
+
full,
|
|
204
|
+
sessions: sample.sessions,
|
|
205
|
+
projections,
|
|
206
|
+
combined: digest([full, sample.sessions, projections]),
|
|
207
|
+
fullParts,
|
|
208
|
+
projectionIds: sample.projectionIds,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
15
211
|
|
|
16
212
|
// a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
|
|
17
213
|
// above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
|
|
@@ -29,88 +225,236 @@ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
|
29
225
|
// the common cause die sooner. Generous: well above the slowest legitimate cold build, so it only ever
|
|
30
226
|
// fires on a genuine wedge.
|
|
31
227
|
const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 120000)
|
|
228
|
+
const RETRY_BACKOFF_MS = Number(process.env.SPEXCODE_BOARD_RETRY_BACKOFF_MS || 1000)
|
|
229
|
+
const BACKGROUND_START_DELAY_MS = Number(process.env.SPEXCODE_BOARD_BACKGROUND_START_DELAY_MS || 300)
|
|
32
230
|
|
|
33
231
|
// the cache's staleness has a DOMAIN, not just a bit: a 'sessions' change (a lifecycle write, a
|
|
34
232
|
// liveness/activity poll flip) touches only the session rows, so the next read can SPLICE fresh sessions
|
|
35
233
|
// onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
|
|
36
|
-
// worktree `.spec` edit,
|
|
37
|
-
// buildBoard(). 'none' = clean.
|
|
234
|
+
// worktree `.spec` edit, or a changed graph-domain patrol revision) can reshape anything, so the next read
|
|
235
|
+
// does the whole buildBoard(). 'none' = clean.
|
|
38
236
|
type Scope = 'sessions' | 'full'
|
|
39
237
|
let cached: Board | null = null // last completed build; served while `dirty === 'none'`
|
|
40
238
|
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
239
|
+
let cachedRevision: BoardInputRevision | null = null // input revision represented by `cached`
|
|
41
240
|
let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
|
|
42
|
-
|
|
43
|
-
let
|
|
241
|
+
type Flight = { wait: Promise<Board>; settle: Promise<Board> }
|
|
242
|
+
let inflight: Flight | null = null
|
|
243
|
+
let gen = 0 // bumped on invalidation so patrol validation cannot certify across an event
|
|
244
|
+
let retryAt = 0
|
|
245
|
+
let lastFailure: Error | null = null
|
|
44
246
|
|
|
45
247
|
// mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
|
|
46
248
|
// boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
|
|
47
249
|
// keeps hitting the cache. The scope only ESCALATES within a dirty window: none→sessions→full, and a
|
|
48
250
|
// 'sessions' signal arriving while 'full' is already pending stays 'full' (a full rebuild subsumes a
|
|
49
|
-
// sessions splice).
|
|
50
|
-
// serialization
|
|
51
|
-
|
|
52
|
-
gen++
|
|
251
|
+
// sessions splice). The last-good JSON stays intact while dirty so stale readers can return it without
|
|
252
|
+
// paying serialization again; a successful replacement clears it.
|
|
253
|
+
function mergeDirty(scope: Scope): void {
|
|
53
254
|
if (scope === 'full' || dirty === 'full') dirty = 'full'
|
|
54
255
|
else dirty = 'sessions'
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function invalidateBoard(scope: Scope = 'full'): void {
|
|
259
|
+
gen++
|
|
260
|
+
mergeDirty(scope)
|
|
261
|
+
retryAt = 0
|
|
262
|
+
lastFailure = null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// One flight owns BOTH patrol validation and a producer. A patrol first folds the cheap input revision; an
|
|
266
|
+
// exact match resolves to `cached` without calling either producer. A mismatch derives the changed domain and
|
|
267
|
+
// falls through to the same build path an explicit watcher invalidation takes. An invalidation during validation
|
|
268
|
+
// also falls through under this flight, so validation can never race a second producer into existence.
|
|
269
|
+
type FlightMode = 'dirty' | 'patrol'
|
|
270
|
+
function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
68
271
|
if (inflight) return inflight
|
|
69
|
-
if (
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
272
|
+
if (Date.now() < retryAt) return null
|
|
273
|
+
const flightStartGen = gen
|
|
274
|
+
const controller = new AbortController()
|
|
275
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
276
|
+
let timedOut = false
|
|
277
|
+
let built = false
|
|
278
|
+
let buildStartedAt = 0
|
|
279
|
+
let buildScope: Scope = 'full'
|
|
280
|
+
let buildFullStable = true
|
|
281
|
+
let buildSessionsStable = true
|
|
282
|
+
let completedRevision: BoardInputRevision | null = null
|
|
283
|
+
// Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
|
|
284
|
+
// synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
|
|
285
|
+
// caller must be able to return its last-good bytes before that setup runs; first-cold/fresh callers still
|
|
286
|
+
// await the same deferred promise below.
|
|
287
|
+
let resolveBuild!: (board: Board) => void
|
|
288
|
+
let rejectBuild!: (error: unknown) => void
|
|
289
|
+
const build = new Promise<Board>((resolve, reject) => {
|
|
290
|
+
resolveBuild = resolve
|
|
291
|
+
rejectBuild = reject
|
|
292
|
+
})
|
|
293
|
+
// Give a stale HTTP response a turn to flush before the producer's synchronous setup occupies the event
|
|
294
|
+
// loop. Fresh callers simply absorb this small scheduling window while waiting on the same flight.
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
if (controller.signal.aborted) {
|
|
297
|
+
rejectBuild(Object.assign(new Error('graph build aborted before start'), { name: 'AbortError' }))
|
|
298
|
+
return
|
|
299
|
+
}
|
|
76
300
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
301
|
+
Promise.resolve(withGitAbortSignal(controller.signal, async () => {
|
|
302
|
+
if (mode === 'patrol' && dirty === 'none' && cached && cachedRevision) {
|
|
303
|
+
const anchorRevision = cachedRevision
|
|
304
|
+
const observed = await boardInputRevision(cached)
|
|
305
|
+
if (controller.signal.aborted)
|
|
306
|
+
throw Object.assign(new Error('graph patrol aborted'), { name: 'AbortError' })
|
|
307
|
+
if (gen === flightStartGen && dirty === 'none' && observed.combined === anchorRevision.combined)
|
|
308
|
+
return cached
|
|
309
|
+
// A changed revision with no watcher event is the patrol doing its repair job. If an explicit event
|
|
310
|
+
// arrived meanwhile it already set the correct (possibly sessions-only) scope, which we must not
|
|
311
|
+
// overwrite. An otherwise-clean mismatch derives its scope from the moved input domain.
|
|
312
|
+
if (gen === flightStartGen && dirty === 'none') {
|
|
313
|
+
gen++
|
|
314
|
+
if (DEBUG) {
|
|
315
|
+
const moved = Object.keys(observed.fullParts)
|
|
316
|
+
.filter((key) => observed.fullParts[key] !== anchorRevision.fullParts[key])
|
|
317
|
+
console.warn(`spec-cli: graph patrol revision moved — scope=${observed.full === anchorRevision.full ? 'sessions' : 'full'} inputs=[${moved.join(', ')}]`)
|
|
318
|
+
}
|
|
319
|
+
dirty = observed.full === anchorRevision.full ? 'sessions' : 'full'
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const prev = cached
|
|
324
|
+
const before = await boardInputRevision(prev)
|
|
325
|
+
if (controller.signal.aborted)
|
|
326
|
+
throw Object.assign(new Error('graph build aborted before producer start'), { name: 'AbortError' })
|
|
327
|
+
// A session signal cannot vouch for the graph domain. Before splicing, compare the current full inputs
|
|
328
|
+
// to the revision the cached node/meta units actually carry; a missed graph watcher promotes this same
|
|
329
|
+
// flight to full instead of certifying old nodes under a new revision.
|
|
330
|
+
if (dirty === 'sessions' && prev && cachedRevision && before.full !== cachedRevision.full) {
|
|
331
|
+
if (DEBUG) console.warn('spec-cli: session refresh found moved graph inputs — promoting to full')
|
|
332
|
+
dirty = 'full'
|
|
333
|
+
}
|
|
334
|
+
const sessionsOnly = dirty === 'sessions' && prev !== null
|
|
335
|
+
buildScope = sessionsOnly ? 'sessions' : 'full'
|
|
336
|
+
// Consume the scope this producer is satisfying. Invalidation after this point starts a NEW dirty
|
|
337
|
+
// window with its own domain: a session completion during a long full build owes one splice, not
|
|
338
|
+
// another full build. The occupied `inflight` slot keeps fresh/stale readers joined while dirty is clean.
|
|
339
|
+
dirty = 'none'
|
|
340
|
+
built = true
|
|
341
|
+
buildStartedAt = Date.now()
|
|
342
|
+
const board = await (sessionsOnly ? spliceSessions(prev!) : buildBoard())
|
|
343
|
+
const after = await boardInputRevision(prev)
|
|
344
|
+
const carried = revisionCarriedByBoard(after, board)
|
|
345
|
+
const movedFull = Object.keys(after.fullParts)
|
|
346
|
+
.filter((key) => after.fullParts[key] !== before.fullParts[key])
|
|
347
|
+
// The first build can initialize the resident forge carrier that it then returns. That movement is
|
|
348
|
+
// stable only when the finished board carries the after-sample's exact issue stamp. Every other cold
|
|
349
|
+
// movement remains dirty just like a warm one; cold start is not a blanket race exemption.
|
|
350
|
+
const coldIssueInitialization = prev === null
|
|
351
|
+
&& movedFull.every((key) => key === 'issuesStamp')
|
|
352
|
+
&& after.fullParts.issuesStamp === carried.fullParts.issuesStamp
|
|
353
|
+
buildFullStable = before.full === after.full || coldIssueInitialization
|
|
354
|
+
buildSessionsStable = before.sessions === after.sessions
|
|
355
|
+
if (DEBUG && (!buildFullStable || !buildSessionsStable)) {
|
|
356
|
+
console.warn(`spec-cli: graph inputs moved during ${buildScope} producer — next=${buildFullStable ? 'sessions' : 'full'} inputs=[${movedFull.join(', ')}] sessions=${buildSessionsStable ? 'stable' : 'moved'}`)
|
|
357
|
+
}
|
|
358
|
+
completedRevision = carried
|
|
359
|
+
return board
|
|
360
|
+
}))
|
|
361
|
+
.then(resolveBuild, rejectBuild)
|
|
362
|
+
} catch (error) {
|
|
363
|
+
rejectBuild(error)
|
|
364
|
+
}
|
|
365
|
+
}, mode === 'patrol' ? 0 : BACKGROUND_START_DELAY_MS).unref?.()
|
|
366
|
+
const timeoutError = () => new Error(`graph build did not settle within ${BUILD_TIMEOUT_MS}ms`)
|
|
367
|
+
|
|
368
|
+
// `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
|
|
369
|
+
// promise settles, so a next read can never overlap an abandoned git/fs build.
|
|
370
|
+
let settle!: Promise<Board>
|
|
371
|
+
settle = build.then((board) => {
|
|
372
|
+
if (timedOut) throw timeoutError()
|
|
373
|
+
if (built) {
|
|
90
374
|
cached = board
|
|
91
|
-
cachedJson = null
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
return board
|
|
96
|
-
} finally {
|
|
97
|
-
clearTimeout(watchdog)
|
|
98
|
-
const ms = Date.now() - t0
|
|
99
|
-
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
|
|
100
|
-
inflight = null
|
|
375
|
+
cachedJson = null
|
|
376
|
+
cachedRevision = completedRevision
|
|
377
|
+
if (!buildFullStable) mergeDirty('full')
|
|
378
|
+
else if (!buildSessionsStable) mergeDirty('sessions')
|
|
101
379
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
380
|
+
retryAt = 0
|
|
381
|
+
lastFailure = null
|
|
382
|
+
return board
|
|
383
|
+
}).catch((error) => {
|
|
384
|
+
const failure = error instanceof Error ? error : new Error(String(error))
|
|
385
|
+
if (built) mergeDirty(buildScope)
|
|
386
|
+
lastFailure = failure
|
|
387
|
+
retryAt = Date.now() + RETRY_BACKOFF_MS
|
|
388
|
+
console.warn(`spec-cli: /api/graph build failed — ${failure.message}`)
|
|
389
|
+
throw failure
|
|
390
|
+
}).finally(() => {
|
|
391
|
+
clearTimeout(watchdog)
|
|
392
|
+
if (inflight?.settle === settle) inflight = null
|
|
393
|
+
const ms = built ? Date.now() - buildStartedAt : 0
|
|
394
|
+
if (built && ms > BUDGET_MS)
|
|
395
|
+
console.warn(`spec-cli: /api/graph build took ${ms}ms (budget ${BUDGET_MS}ms) — ${buildScope} path is slow`)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const wait = new Promise<Board>((resolve, reject) => {
|
|
399
|
+
watchdog = setTimeout(() => {
|
|
400
|
+
timedOut = true
|
|
401
|
+
console.warn(`spec-cli: /api/graph build did not settle within ${BUILD_TIMEOUT_MS}ms — aborting the single-flight build`)
|
|
402
|
+
controller.abort()
|
|
403
|
+
reject(timeoutError())
|
|
404
|
+
}, BUILD_TIMEOUT_MS)
|
|
405
|
+
watchdog.unref?.()
|
|
406
|
+
settle.then((board) => {
|
|
407
|
+
if (!timedOut) resolve(board)
|
|
408
|
+
}, (error) => {
|
|
409
|
+
if (!timedOut) reject(error)
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
const flight = { wait, settle }
|
|
413
|
+
inflight = flight
|
|
414
|
+
// Background stale readers intentionally do not await these promises. Observe both rejection paths so a
|
|
415
|
+
// failed build is loud without becoming an unhandled rejection.
|
|
416
|
+
void wait.catch(() => {})
|
|
417
|
+
void settle.catch(() => {})
|
|
418
|
+
return flight
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function getBoard(): Promise<Board> {
|
|
422
|
+
// A clean-looking cache can be under patrol validation. Fresh readers join that flight before taking the
|
|
423
|
+
// cache fast path, otherwise one can return stale bytes while the flight is discovering a missed change.
|
|
424
|
+
if (inflight) return inflight.wait
|
|
425
|
+
if (dirty === 'none' && cached) return Promise.resolve(cached)
|
|
426
|
+
const flight = startBuild('dirty')
|
|
427
|
+
if (flight) return flight.wait
|
|
428
|
+
return Promise.reject(lastFailure ?? new Error('graph build retry is temporarily backing off'))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The delta-gated cold tick calls this instead of invalidating. Equal inputs resolve to the cached object;
|
|
432
|
+
// changed inputs repair through the same flight and full producer as a watcher-owned invalidation.
|
|
433
|
+
export function patrolBoard(): Promise<Board> {
|
|
434
|
+
if (!cached) return getBoard()
|
|
435
|
+
const flight = startBuild('patrol')
|
|
436
|
+
if (flight) return flight.wait
|
|
437
|
+
return Promise.reject(lastFailure ?? new Error('graph patrol retry is temporarily backing off'))
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function readBoard(consistency: BoardConsistency = 'fresh'): Promise<BoardRead> {
|
|
441
|
+
if (consistency === 'stale-ok' && cached) {
|
|
442
|
+
const stale = dirty !== 'none' || inflight !== null
|
|
443
|
+
const flight = stale ? startBuild('dirty') : null
|
|
444
|
+
return { board: cached, freshness: stale ? 'stale' : 'fresh', refreshing: !!flight, ...(lastFailure ? { error: lastFailure.message } : {}) }
|
|
445
|
+
}
|
|
446
|
+
const board = await getBoard()
|
|
447
|
+
return { board, freshness: 'fresh', refreshing: false }
|
|
105
448
|
}
|
|
106
449
|
|
|
107
450
|
// the SERIALIZED board for the /api/graph route — JSON.stringify runs ONCE per build, not once per poll,
|
|
108
451
|
// so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
|
|
109
452
|
// path still takes the object (getBoard) because it decomposes it into delta units ([[graph-delta]]).
|
|
110
|
-
export async function getBoardJson(): Promise<
|
|
111
|
-
const
|
|
112
|
-
|
|
453
|
+
export async function getBoardJson(consistency: BoardConsistency = 'fresh'): Promise<BoardJsonRead> {
|
|
454
|
+
const result = await readBoard(consistency)
|
|
455
|
+
const board = result.board
|
|
456
|
+
if (board === cached && cachedJson !== null) return { ...result, json: cachedJson }
|
|
113
457
|
const json = JSON.stringify(board)
|
|
114
458
|
if (board === cached) cachedJson = json // memoize only the CURRENT build's serialization
|
|
115
|
-
return json
|
|
459
|
+
return { ...result, json }
|
|
116
460
|
}
|