spexcode 0.5.8 → 0.6.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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +122 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +71 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +91 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +235 -1021
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -1,30 +1,25 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
3
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
4
|
-
import { buildBoard, spliceSessions } from './graph.js'
|
|
4
|
+
import { buildBoard, rebasePublishedSessions, spliceSessions } from './graph.js'
|
|
5
5
|
import { headSha, repoRoot, withGitAbortSignal } from './git.js'
|
|
6
6
|
import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from './layout.js'
|
|
7
7
|
import { boardThreads } from './issues.js'
|
|
8
8
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
9
9
|
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
10
10
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
11
|
+
import { readReviewSnapshot } from './reviewSnapshot.js'
|
|
11
12
|
import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
|
|
12
13
|
|
|
13
|
-
// @@@ graph-cache — single-flight + cache for the hot /api/graph build ([[graph-lean]]). Assembling the
|
|
14
|
-
// board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
|
|
15
|
-
// route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
|
|
16
|
-
// dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
|
|
17
|
-
// starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
|
|
18
|
-
// concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
|
|
19
|
-
// result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
|
|
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.
|
|
23
|
-
|
|
24
14
|
export type Board = Awaited<ReturnType<typeof buildBoard>>
|
|
25
15
|
export type BoardConsistency = 'fresh' | 'stale-ok'
|
|
26
16
|
export type BoardRead = { board: Board; freshness: 'fresh' | 'stale'; refreshing: boolean; error?: string }
|
|
27
17
|
export type BoardJsonRead = BoardRead & { json: string }
|
|
18
|
+
export type RevisionPublication = {
|
|
19
|
+
revision: () => number
|
|
20
|
+
invalidate: () => void
|
|
21
|
+
wait: () => Promise<void>
|
|
22
|
+
}
|
|
28
23
|
|
|
29
24
|
type BoardInputRevision = {
|
|
30
25
|
full: string
|
|
@@ -34,6 +29,7 @@ type BoardInputRevision = {
|
|
|
34
29
|
fullParts: Record<string, string>
|
|
35
30
|
projectionIds: string[]
|
|
36
31
|
}
|
|
32
|
+
type SessionInputRevision = Pick<BoardInputRevision, 'sessions' | 'projections' | 'projectionIds'>
|
|
37
33
|
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
38
34
|
|
|
39
35
|
function textOrNull(path: string): string | null {
|
|
@@ -146,8 +142,7 @@ function digest(value: unknown): string {
|
|
|
146
142
|
// the issue-store carrier, and the already-resident session-eval projections. It intentionally does not call
|
|
147
143
|
// listSessions or sessionEvalProjections: verification must neither poll tmux again nor mint/schedule eval work.
|
|
148
144
|
// The hot/warm liveness signatures and eval generations remain graph-stream's canonical event-owned axes.
|
|
149
|
-
function
|
|
150
|
-
const root = repoRoot()
|
|
145
|
+
function sessionInputRevision(): SessionInputRevision {
|
|
151
146
|
const ids = listSessionIds().sort()
|
|
152
147
|
// listSessions projects both the structured record and the separately-stored originating prompt into each
|
|
153
148
|
// board row. Fold both exact artifacts so a missed store event cannot leave a stale label/prompt forever.
|
|
@@ -156,13 +151,19 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
|
156
151
|
textOrNull(sessionRecordPath(id)),
|
|
157
152
|
textOrNull(sessionArtifactPath(id, 'prompt')),
|
|
158
153
|
] as const)
|
|
159
|
-
const
|
|
154
|
+
const projections = digest(ids.map((id) => [id, sessionEvalProjection(id)]))
|
|
155
|
+
return { sessions: digest(sessionInputs), projections, projectionIds: ids }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
159
|
+
const root = repoRoot()
|
|
160
|
+
const session = sessionInputRevision()
|
|
161
|
+
const records = listSessionIds().map(readPublicRecordEntry).flatMap((entry) => entry.kind === 'ok' ? [entry.raw] : [])
|
|
160
162
|
const governed = records.filter((record) => record.governed).sort((a, b) => a.session_id.localeCompare(b.session_id))
|
|
161
163
|
const activeRoots = [...new Set(governed.filter((record) => !record.archived).map((record) => record.worktree_path))].sort()
|
|
162
164
|
const main = mainCheckout()
|
|
163
165
|
const base = mainBranch()
|
|
164
166
|
const mainTip = refSha(main, base)
|
|
165
|
-
|
|
166
167
|
const nodeIds = (board?.nodes ?? []).map((node) => node.id)
|
|
167
168
|
const issuesStamp = boardThreads({ host: resolveForgeHost(), state: residentForgeState() }, nodeIds).stamp
|
|
168
169
|
const fullInputs = {
|
|
@@ -180,14 +181,12 @@ function boardInputRevision(board: Board | null): BoardInputRevision {
|
|
|
180
181
|
}
|
|
181
182
|
const fullParts = Object.fromEntries(Object.entries(fullInputs).map(([key, value]) => [key, digest(value)]))
|
|
182
183
|
const full = digest(fullParts)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const projections = digest(projectionIds.map((id) => [id, sessionEvalProjection(id)]))
|
|
190
|
-
return { full, sessions, projections, combined: digest([full, sessions, projections]), fullParts, projectionIds }
|
|
184
|
+
return {
|
|
185
|
+
full,
|
|
186
|
+
...session,
|
|
187
|
+
combined: digest([full, session.sessions, session.projections]),
|
|
188
|
+
fullParts,
|
|
189
|
+
}
|
|
191
190
|
}
|
|
192
191
|
|
|
193
192
|
// Bind an input sample to what the completed board actually carries. The sample supplies the graph/session
|
|
@@ -209,6 +208,38 @@ function revisionCarriedByBoard(sample: BoardInputRevision, board: Board): Board
|
|
|
209
208
|
}
|
|
210
209
|
}
|
|
211
210
|
|
|
211
|
+
// A sessions splice reuses its base topology. It may sample fresh record/projection inputs, but it must never
|
|
212
|
+
// certify that those old nodes carry a full revision sampled after the base was built.
|
|
213
|
+
function revisionCarriedBySessionSplice(base: BoardInputRevision, sample: SessionInputRevision, board: Board, stable: boolean): BoardInputRevision {
|
|
214
|
+
const boardProjections = new Map(board.sessions.map((session) => [session.id, session.evalSummary ?? null]))
|
|
215
|
+
const projections = stable
|
|
216
|
+
? digest(sample.projectionIds.map((id) => [id, boardProjections.get(id) ?? null]))
|
|
217
|
+
: sample.projections
|
|
218
|
+
return {
|
|
219
|
+
full: base.full,
|
|
220
|
+
sessions: sample.sessions,
|
|
221
|
+
projections,
|
|
222
|
+
combined: digest([base.full, sample.sessions, projections]),
|
|
223
|
+
fullParts: base.fullParts,
|
|
224
|
+
projectionIds: sample.projectionIds,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The full producer's topology may be newer than the last published session projection. When completion
|
|
229
|
+
// re-bases that already-visible projection onto the new topology, keep the full carrier from the producer and
|
|
230
|
+
// only the sessions/projections carrier from the published rows. Do not sample current inputs here: that would
|
|
231
|
+
// certify a write that neither producer actually carried.
|
|
232
|
+
function revisionCarriedByPublishedSessionRebase(full: BoardInputRevision, published: BoardInputRevision): BoardInputRevision {
|
|
233
|
+
return {
|
|
234
|
+
full: full.full,
|
|
235
|
+
sessions: published.sessions,
|
|
236
|
+
projections: published.projections,
|
|
237
|
+
combined: digest([full.full, published.sessions, published.projections]),
|
|
238
|
+
fullParts: full.fullParts,
|
|
239
|
+
projectionIds: published.projectionIds,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
// a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
|
|
213
244
|
// above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
|
|
214
245
|
// genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
|
|
@@ -228,11 +259,8 @@ const BUILD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_BUILD_TIMEOUT_MS || 1
|
|
|
228
259
|
const RETRY_BACKOFF_MS = Number(process.env.SPEXCODE_BOARD_RETRY_BACKOFF_MS || 1000)
|
|
229
260
|
const BACKGROUND_START_DELAY_MS = Number(process.env.SPEXCODE_BOARD_BACKGROUND_START_DELAY_MS || 300)
|
|
230
261
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// onto the still-valid node/meta units instead of re-walking git+`.spec`; a 'full' change (a ref move, a
|
|
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.
|
|
262
|
+
// The structural/full and sessions obligations are deliberately separate. A full build can take seconds;
|
|
263
|
+
// a persisted session row cannot be silently converted into time behind that unrelated topology work.
|
|
236
264
|
type Scope = 'sessions' | 'full'
|
|
237
265
|
let cached: Board | null = null // last completed build; served while `dirty === 'none'`
|
|
238
266
|
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
@@ -240,16 +268,19 @@ let cachedRevision: BoardInputRevision | null = null // input revision represent
|
|
|
240
268
|
let dirty: Scope | 'none' = 'full' // no cached board yet → the first read builds fully
|
|
241
269
|
type Flight = { wait: Promise<Board>; settle: Promise<Board> }
|
|
242
270
|
let inflight: Flight | null = null
|
|
271
|
+
let sessionFlight: Flight | null = null
|
|
272
|
+
let sessionOwed = false
|
|
273
|
+
let sessionGeneration = 0
|
|
274
|
+
let sessionProjectionPublication = 0
|
|
275
|
+
let topologyGeneration = 0
|
|
243
276
|
let gen = 0 // bumped on invalidation so patrol validation cannot certify across an event
|
|
244
277
|
let retryAt = 0
|
|
245
278
|
let lastFailure: Error | null = null
|
|
246
279
|
|
|
247
280
|
// mark the cache stale at a SCOPE. Called by every board-stream freshness source (see
|
|
248
281
|
// boardStream.fireChanged), so a real change forces the next getBoard() to rebuild while a quiet poll storm
|
|
249
|
-
// keeps hitting the cache.
|
|
250
|
-
//
|
|
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.
|
|
282
|
+
// keeps hitting the cache. `dirty` is the structural producer's scope; `sessionOwed` is intentionally not
|
|
283
|
+
// folded into it, because full+sessions owes both a full convergence and a cheap first projection.
|
|
253
284
|
function mergeDirty(scope: Scope): void {
|
|
254
285
|
if (scope === 'full' || dirty === 'full') dirty = 'full'
|
|
255
286
|
else dirty = 'sessions'
|
|
@@ -257,20 +288,90 @@ function mergeDirty(scope: Scope): void {
|
|
|
257
288
|
|
|
258
289
|
export function invalidateBoard(scope: Scope = 'full'): void {
|
|
259
290
|
gen++
|
|
291
|
+
if (scope === 'sessions') { sessionOwed = true; sessionGeneration++ }
|
|
260
292
|
mergeDirty(scope)
|
|
261
293
|
retryAt = 0
|
|
262
294
|
lastFailure = null
|
|
263
295
|
}
|
|
264
296
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
297
|
+
function traceCacheCommit(scope: Scope, startedAt: number): void {
|
|
298
|
+
if (!DEBUG) return
|
|
299
|
+
const at = Date.now()
|
|
300
|
+
console.warn(`spec-cli: graph cache ${JSON.stringify({ at, stage: 'cache-commit', scope, buildMs: at - startedAt })}`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function startSessionSplice(): Flight | null {
|
|
304
|
+
if (!cached || !cachedRevision || !sessionOwed) return null
|
|
305
|
+
if (sessionFlight) return sessionFlight
|
|
306
|
+
sessionOwed = false
|
|
307
|
+
if (dirty === 'sessions') dirty = 'none'
|
|
308
|
+
let timedOut = false
|
|
309
|
+
const startedAt = Date.now()
|
|
310
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
311
|
+
const timeoutError = () => new Error(`graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`)
|
|
312
|
+
const producer = Promise.resolve().then(async () => {
|
|
313
|
+
// A full completion may replace the topology while listSessions is in flight. Rebase before publishing;
|
|
314
|
+
// the splice is a projection over whatever last-good structure is current, never a whole-board write race.
|
|
315
|
+
while (true) {
|
|
316
|
+
const base = cached, revision = cachedRevision, generation = topologyGeneration
|
|
317
|
+
if (!base || !revision) throw new Error('graph session splice lost its cached topology')
|
|
318
|
+
const before = sessionInputRevision()
|
|
319
|
+
const board = await spliceSessions(base)
|
|
320
|
+
const after = sessionInputRevision()
|
|
321
|
+
if (base !== cached || revision !== cachedRevision || generation !== topologyGeneration) continue
|
|
322
|
+
const stable = before.sessions === after.sessions && before.projections === after.projections
|
|
323
|
+
cached = board
|
|
324
|
+
cachedJson = null
|
|
325
|
+
cachedRevision = revisionCarriedBySessionSplice(revision, before, board, stable)
|
|
326
|
+
sessionProjectionPublication++
|
|
327
|
+
if (!stable) {
|
|
328
|
+
sessionOwed = true
|
|
329
|
+
sessionGeneration++
|
|
330
|
+
mergeDirty('sessions')
|
|
331
|
+
}
|
|
332
|
+
return board
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
.then((board) => {
|
|
336
|
+
if (timedOut) throw timeoutError()
|
|
337
|
+
traceCacheCommit('sessions', startedAt)
|
|
338
|
+
return board
|
|
339
|
+
})
|
|
340
|
+
.catch((error) => {
|
|
341
|
+
sessionOwed = true
|
|
342
|
+
mergeDirty('sessions')
|
|
343
|
+
throw error
|
|
344
|
+
})
|
|
345
|
+
let settle!: Promise<Board>
|
|
346
|
+
settle = producer.finally(() => {
|
|
347
|
+
clearTimeout(watchdog)
|
|
348
|
+
if (sessionFlight?.settle === settle) sessionFlight = null
|
|
349
|
+
})
|
|
350
|
+
const wait = new Promise<Board>((resolve, reject) => {
|
|
351
|
+
watchdog = setTimeout(() => {
|
|
352
|
+
timedOut = true
|
|
353
|
+
console.warn(`spec-cli: graph session splice did not settle within ${BUILD_TIMEOUT_MS}ms`)
|
|
354
|
+
reject(timeoutError())
|
|
355
|
+
}, BUILD_TIMEOUT_MS)
|
|
356
|
+
watchdog.unref?.()
|
|
357
|
+
settle.then((board) => { if (!timedOut) resolve(board) }, (error) => { if (!timedOut) reject(error) })
|
|
358
|
+
})
|
|
359
|
+
const flight = { wait, settle }
|
|
360
|
+
sessionFlight = flight
|
|
361
|
+
void wait.catch(() => {})
|
|
362
|
+
void settle.catch(() => {})
|
|
363
|
+
return flight
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// One flight owns BOTH validation and a producer. Every refresh — patrol or watcher-signalled — first folds
|
|
367
|
+
// the cheap input revision; an exact match resolves to `cached` without calling either producer. A mismatch
|
|
368
|
+
// derives the changed domain and falls through to the build path. An invalidation during validation also falls
|
|
369
|
+
// through under this flight, so validation can never race a second producer into existence. `mode` no longer
|
|
370
|
+
// selects who validates; it only says whether a stale HTTP response is waiting for a turn to flush first.
|
|
269
371
|
type FlightMode = 'dirty' | 'patrol'
|
|
270
372
|
function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
271
373
|
if (inflight) return inflight
|
|
272
374
|
if (Date.now() < retryAt) return null
|
|
273
|
-
const flightStartGen = gen
|
|
274
375
|
const controller = new AbortController()
|
|
275
376
|
let watchdog: ReturnType<typeof setTimeout> | undefined
|
|
276
377
|
let timedOut = false
|
|
@@ -279,6 +380,9 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
279
380
|
let buildScope: Scope = 'full'
|
|
280
381
|
let buildFullStable = true
|
|
281
382
|
let buildSessionsStable = true
|
|
383
|
+
let buildSessionGeneration = sessionGeneration
|
|
384
|
+
let buildSessionProjectionPublication = sessionProjectionPublication
|
|
385
|
+
let buildStartedWithSessionOwed = false
|
|
282
386
|
let completedRevision: BoardInputRevision | null = null
|
|
283
387
|
// Do not invoke the producer inline. buildBoard() has an asynchronous signature but performs a sizeable
|
|
284
388
|
// synchronous setup before its first await (Promise.all evaluates its arguments immediately). A stale HTTP
|
|
@@ -299,37 +403,42 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
299
403
|
}
|
|
300
404
|
try {
|
|
301
405
|
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
406
|
const prev = cached
|
|
407
|
+
const anchor = cachedRevision
|
|
408
|
+
const sampledGen = gen
|
|
324
409
|
const before = await boardInputRevision(prev)
|
|
325
410
|
if (controller.signal.aborted)
|
|
326
411
|
throw Object.assign(new Error('graph build aborted before producer start'), { name: 'AbortError' })
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
412
|
+
// ONE rule for every refresh: the domain a producer runs is DERIVED from the inputs that actually
|
|
413
|
+
// moved, never assigned by whoever signalled. A watcher names the leaf it saw, not what the board
|
|
414
|
+
// reads — a generated harness artifact rewritten inside a live worktree, or a linked worktree no
|
|
415
|
+
// governed record names, moves no board input at all, and used to buy a whole structural assembly
|
|
416
|
+
// anyway. The revision sampled here is the complete full-domain contract, so an unmoved full
|
|
417
|
+
// obligation is DISCHARGED without assembly. The session projection is deliberately NOT
|
|
418
|
+
// dischargeable: liveness lives on graph-stream's poller axis, outside this revision, so a claimed
|
|
419
|
+
// sessions obligation always survives and takes its splice. A sample taken after the write is what
|
|
420
|
+
// the watcher event was about; an invalidation arriving after the sample keeps its own dirty window.
|
|
421
|
+
if (prev && anchor && gen === sampledGen) {
|
|
422
|
+
const fullMoved = before.full !== anchor.full
|
|
423
|
+
const projectionMoved = before.sessions !== anchor.sessions || before.projections !== anchor.projections
|
|
424
|
+
// A projection move nobody signalled is this validation doing the patrol's repair job.
|
|
425
|
+
if (projectionMoved && !sessionOwed && dirty !== 'sessions') {
|
|
426
|
+
gen++
|
|
427
|
+
sessionOwed = true
|
|
428
|
+
sessionGeneration++
|
|
429
|
+
}
|
|
430
|
+
const owed = fullMoved ? 'full' : (sessionOwed || dirty === 'sessions') ? 'sessions' : 'none'
|
|
431
|
+
if (DEBUG && owed !== 'none') {
|
|
432
|
+
const moved = Object.keys(before.fullParts).filter((key) => before.fullParts[key] !== anchor.fullParts[key])
|
|
433
|
+
console.warn(`spec-cli: graph refresh revision moved — signalled=${dirty} scope=${owed} inputs=[${moved.join(', ')}]`)
|
|
434
|
+
} else if (DEBUG && dirty !== 'none') {
|
|
435
|
+
console.warn(`spec-cli: graph refresh discharged a ${dirty} signal — no board input moved`)
|
|
436
|
+
}
|
|
437
|
+
if (fullMoved) dirty = 'full'
|
|
438
|
+
else if (sessionOwed || dirty === 'sessions') dirty = 'sessions'
|
|
439
|
+
// Discharging CONSUMES the claim. Leaving it standing would make every later read re-enter this
|
|
440
|
+
// validation, hold the board permanently stale/refreshing, and never converge.
|
|
441
|
+
else { dirty = 'none'; return prev }
|
|
333
442
|
}
|
|
334
443
|
const sessionsOnly = dirty === 'sessions' && prev !== null
|
|
335
444
|
buildScope = sessionsOnly ? 'sessions' : 'full'
|
|
@@ -337,11 +446,14 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
337
446
|
// window with its own domain: a session completion during a long full build owes one splice, not
|
|
338
447
|
// another full build. The occupied `inflight` slot keeps fresh/stale readers joined while dirty is clean.
|
|
339
448
|
dirty = 'none'
|
|
449
|
+
if (sessionsOnly) sessionOwed = false
|
|
340
450
|
built = true
|
|
451
|
+
buildSessionGeneration = sessionGeneration
|
|
452
|
+
buildSessionProjectionPublication = sessionProjectionPublication
|
|
453
|
+
buildStartedWithSessionOwed = sessionOwed
|
|
341
454
|
buildStartedAt = Date.now()
|
|
342
455
|
const board = await (sessionsOnly ? spliceSessions(prev!) : buildBoard())
|
|
343
456
|
const after = await boardInputRevision(prev)
|
|
344
|
-
const carried = revisionCarriedByBoard(after, board)
|
|
345
457
|
const movedFull = Object.keys(after.fullParts)
|
|
346
458
|
.filter((key) => after.fullParts[key] !== before.fullParts[key])
|
|
347
459
|
// The first build can initialize the resident forge carrier that it then returns. That movement is
|
|
@@ -349,13 +461,20 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
349
461
|
// movement remains dirty just like a warm one; cold start is not a blanket race exemption.
|
|
350
462
|
const coldIssueInitialization = prev === null
|
|
351
463
|
&& movedFull.every((key) => key === 'issuesStamp')
|
|
352
|
-
&& after.fullParts.issuesStamp ===
|
|
464
|
+
&& after.fullParts.issuesStamp === digest(board.issuesStamp)
|
|
353
465
|
buildFullStable = before.full === after.full || coldIssueInitialization
|
|
354
466
|
buildSessionsStable = before.sessions === after.sessions
|
|
355
467
|
if (DEBUG && (!buildFullStable || !buildSessionsStable)) {
|
|
356
468
|
console.warn(`spec-cli: graph inputs moved during ${buildScope} producer — next=${buildFullStable ? 'sessions' : 'full'} inputs=[${movedFull.join(', ')}] sessions=${buildSessionsStable ? 'stable' : 'moved'}`)
|
|
357
469
|
}
|
|
358
|
-
|
|
470
|
+
// The anchor must name the sample this board is KNOWN to have read, because validation now discharges
|
|
471
|
+
// an unmoved obligation for every refresh, not only the patrol's. An input that moved DURING the
|
|
472
|
+
// producer leaves the board built from the pre-move value while the after-sample already names the
|
|
473
|
+
// post-move one; anchoring on `after` would let the next validation discharge the re-owed dirty scope
|
|
474
|
+
// against a revision this board never carried, and the cache would converge on nothing. So a moved
|
|
475
|
+
// half anchors on `before` — the conservative direction, which costs at most one extra rebuild and can
|
|
476
|
+
// never certify unread bytes.
|
|
477
|
+
completedRevision = revisionCarriedByBoard(buildFullStable && buildSessionsStable ? after : before, board)
|
|
359
478
|
return board
|
|
360
479
|
}))
|
|
361
480
|
.then(resolveBuild, rejectBuild)
|
|
@@ -368,14 +487,31 @@ function startBuild(mode: FlightMode = 'dirty'): Flight | null {
|
|
|
368
487
|
// `settle` owns the real builder. The watchdog only rejects `wait`; the slot remains occupied until this
|
|
369
488
|
// promise settles, so a next read can never overlap an abandoned git/fs build.
|
|
370
489
|
let settle!: Promise<Board>
|
|
371
|
-
settle = build.then((board) => {
|
|
490
|
+
settle = build.then(async (board) => {
|
|
372
491
|
if (timedOut) throw timeoutError()
|
|
373
492
|
if (built) {
|
|
493
|
+
// A full build's session snapshot may predate a session projection that clients already saw. Rebase those
|
|
494
|
+
// published rows in memory on the new topology. This completion path must stay bounded: a later/unpublished
|
|
495
|
+
// session generation remains owed to the independent cheap splice rather than making full wait for quiet.
|
|
496
|
+
const publishedDuringBuild = sessionProjectionPublication !== buildSessionProjectionPublication
|
|
497
|
+
if (buildScope === 'full' && publishedDuringBuild && cached && cachedRevision) {
|
|
498
|
+
board = rebasePublishedSessions(board, cached)
|
|
499
|
+
completedRevision = revisionCarriedByPublishedSessionRebase(completedRevision!, cachedRevision)
|
|
500
|
+
}
|
|
501
|
+
const sessionStillOwed = sessionOwed || (!publishedDuringBuild && (
|
|
502
|
+
buildStartedWithSessionOwed || !buildSessionsStable || buildSessionGeneration !== sessionGeneration
|
|
503
|
+
))
|
|
504
|
+
if (sessionStillOwed) {
|
|
505
|
+
sessionOwed = true
|
|
506
|
+
mergeDirty('sessions')
|
|
507
|
+
}
|
|
374
508
|
cached = board
|
|
375
509
|
cachedJson = null
|
|
376
510
|
cachedRevision = completedRevision
|
|
511
|
+
if (buildScope === 'full') topologyGeneration++
|
|
512
|
+
else sessionProjectionPublication++
|
|
377
513
|
if (!buildFullStable) mergeDirty('full')
|
|
378
|
-
|
|
514
|
+
traceCacheCommit(buildScope, buildStartedAt)
|
|
379
515
|
}
|
|
380
516
|
retryAt = 0
|
|
381
517
|
lastFailure = null
|
|
@@ -422,12 +558,51 @@ export function getBoard(): Promise<Board> {
|
|
|
422
558
|
// A clean-looking cache can be under patrol validation. Fresh readers join that flight before taking the
|
|
423
559
|
// cache fast path, otherwise one can return stale bytes while the flight is discovering a missed change.
|
|
424
560
|
if (inflight) return inflight.wait
|
|
561
|
+
if (dirty === 'full') {
|
|
562
|
+
const flight = startBuild('dirty')
|
|
563
|
+
if (flight) return flight.wait
|
|
564
|
+
}
|
|
565
|
+
if (sessionFlight) return sessionFlight.wait
|
|
425
566
|
if (dirty === 'none' && cached) return Promise.resolve(cached)
|
|
426
567
|
const flight = startBuild('dirty')
|
|
427
568
|
if (flight) return flight.wait
|
|
428
569
|
return Promise.reject(lastFailure ?? new Error('graph build retry is temporarily backing off'))
|
|
429
570
|
}
|
|
430
571
|
|
|
572
|
+
export async function waitForPublishedRevision(required: number, publication: RevisionPublication): Promise<void> {
|
|
573
|
+
while (publication.revision() < required) {
|
|
574
|
+
publication.invalidate()
|
|
575
|
+
await publication.wait()
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// A board flight can have captured a resident forge slice before its reconcile publishes a newer revision.
|
|
580
|
+
// That older flight may settle, but cannot discharge this request: its invalidation stays owed and the fence
|
|
581
|
+
// consumes the next publication instead of letting a fresh /api/issues read return the old review snapshot.
|
|
582
|
+
export async function getBoardForForgeRevision(required: number): Promise<Board> {
|
|
583
|
+
await waitForPublishedRevision(required, {
|
|
584
|
+
revision: () => readReviewSnapshot().forgeRevision,
|
|
585
|
+
invalidate: () => invalidateBoard('full'),
|
|
586
|
+
wait: async () => {
|
|
587
|
+
const current = inflight
|
|
588
|
+
if (current) await current.settle
|
|
589
|
+
else await getBoard()
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
return cached ?? getBoard()
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Delta delivery may choose the sessions obligation while a route-owned full is still in flight. Starting
|
|
596
|
+
// the full first preserves structural convergence; returning the splice first keeps the lifecycle surface live.
|
|
597
|
+
export function getBoardForSessionRefresh(): Promise<Board> {
|
|
598
|
+
if (cached && sessionOwed) {
|
|
599
|
+
if (!inflight && dirty === 'full') startBuild('dirty')
|
|
600
|
+
const flight = startSessionSplice()
|
|
601
|
+
if (flight) return flight.wait
|
|
602
|
+
}
|
|
603
|
+
return getBoard()
|
|
604
|
+
}
|
|
605
|
+
|
|
431
606
|
// The delta-gated cold tick calls this instead of invalidating. Equal inputs resolve to the cached object;
|
|
432
607
|
// changed inputs repair through the same flight and full producer as a watcher-owned invalidation.
|
|
433
608
|
export function patrolBoard(): Promise<Board> {
|
|
@@ -439,8 +614,14 @@ export function patrolBoard(): Promise<Board> {
|
|
|
439
614
|
|
|
440
615
|
export async function readBoard(consistency: BoardConsistency = 'fresh'): Promise<BoardRead> {
|
|
441
616
|
if (consistency === 'stale-ok' && cached) {
|
|
442
|
-
const stale = dirty !== 'none' || inflight !== null
|
|
443
|
-
|
|
617
|
+
const stale = dirty !== 'none' || inflight !== null || sessionFlight !== null || sessionOwed
|
|
618
|
+
// A held session splice already owns this refresh. Returning stale bytes must not manufacture a full
|
|
619
|
+
// producer beside it; a real full obligation still starts its one structural producer independently.
|
|
620
|
+
const flight = inflight
|
|
621
|
+
?? (dirty === 'full' ? startBuild('dirty') : null)
|
|
622
|
+
?? sessionFlight
|
|
623
|
+
?? (sessionOwed ? startSessionSplice() : null)
|
|
624
|
+
?? (dirty === 'sessions' ? startBuild('dirty') : null)
|
|
444
625
|
return { board: cached, freshness: stale ? 'stale' : 'fresh', refreshing: !!flight, ...(lastFailure ? { error: lastFailure.message } : {}) }
|
|
445
626
|
}
|
|
446
627
|
const board = await getBoard()
|
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
|
|
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 ([[graph-stream]])
|
|
5
|
-
// chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
|
|
6
|
-
// instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
|
|
7
|
-
// dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
|
|
8
|
-
// equivalence argument (see the spec node's equivalence.md) is checkable by the property tests alone.
|
|
9
|
-
//
|
|
10
|
-
// Unit keys: `node:<id>` (one spec node), `sess:<id>` (one session row), `nodes#order` / `sess#order`
|
|
11
|
-
// (id sequences, preserving array order), `meta` (every other top-level field as one small object).
|
|
12
|
-
// Precondition P: node ids and session ids are collision-free. unitize REPORTS P (`ok`) rather than
|
|
13
|
-
// assuming it — on a violation the transport falls back to full-snapshot sends, so a delta is only ever
|
|
14
|
-
// chained between snapshots where the decomposition is a real bijection.
|
|
15
|
-
|
|
16
3
|
export type Units = Map<string, { j: string; v: unknown }>
|
|
17
4
|
export type Delta = { from: string; to: string; set: Record<string, unknown>; del: string[] }
|
|
18
5
|
|