spexcode 0.4.0 → 0.4.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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
package/spec-cli/src/git.ts
CHANGED
|
@@ -443,7 +443,7 @@ function parseNameStatus(out: string): { code: string; from: string; to: string
|
|
|
443
443
|
return rows
|
|
444
444
|
}
|
|
445
445
|
|
|
446
|
-
export type ReviewDiffFile = { path: string; status: string; additions: number; deletions: number }
|
|
446
|
+
export type ReviewDiffFile = { path: string; oldPath?: string; status: string; additions: number; deletions: number }
|
|
447
447
|
const DIFF_STATUS: Record<string, string> = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' }
|
|
448
448
|
export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<ReviewDiffFile[]> {
|
|
449
449
|
const run = (args: string[]) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args])
|
|
@@ -453,14 +453,21 @@ export async function mergeBaseDiff(wtPath: string, mainRef = 'main'): Promise<R
|
|
|
453
453
|
run(['diff', '--numstat', '-M', `${base}..HEAD`]),
|
|
454
454
|
run(['diff', '--name-status', '-M', `${base}..HEAD`]),
|
|
455
455
|
])
|
|
456
|
-
const status = new Map<string, string>()
|
|
457
|
-
for (const r of parseNameStatus(statusOut)) status.set(r.to, DIFF_STATUS[r.code] ?? r.code)
|
|
456
|
+
const status = new Map<string, { status: string; from: string }>()
|
|
457
|
+
for (const r of parseNameStatus(statusOut)) status.set(r.to, { status: DIFF_STATUS[r.code] ?? r.code, from: r.from })
|
|
458
458
|
const files: ReviewDiffFile[] = []
|
|
459
459
|
for (const line of numstatOut.split('\n')) {
|
|
460
460
|
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/)
|
|
461
461
|
if (!m) continue
|
|
462
|
-
const { to } = parseStatPath(m[3])
|
|
463
|
-
|
|
462
|
+
const { from, to } = parseStatPath(m[3])
|
|
463
|
+
const detail = status.get(to)
|
|
464
|
+
files.push({
|
|
465
|
+
path: to,
|
|
466
|
+
...(from !== to ? { oldPath: detail?.from ?? from } : {}),
|
|
467
|
+
status: detail?.status ?? 'modified',
|
|
468
|
+
additions: m[1] === '-' ? 0 : +m[1],
|
|
469
|
+
deletions: m[2] === '-' ? 0 : +m[2],
|
|
470
|
+
})
|
|
464
471
|
}
|
|
465
472
|
return files
|
|
466
473
|
}
|
package/spec-cli/src/graph.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { basename } from 'path'
|
|
2
1
|
import { loadSpecs, deriveStatus } from './specs.js'
|
|
3
|
-
import { resolveLayout
|
|
2
|
+
import { resolveLayout } from './layout.js'
|
|
4
3
|
import { listSessions } from './sessions.js'
|
|
5
4
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
6
5
|
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
@@ -8,6 +7,11 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
8
7
|
import { mergedIssues } from './issues.js'
|
|
9
8
|
import { evalContext, evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
10
9
|
import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
|
|
10
|
+
import { resolveProjectIdentity } from './project-identity.js'
|
|
11
|
+
import { sessionEvalProjections } from '../../spec-eval/src/sessioneval.js'
|
|
12
|
+
import { publishReviewSnapshot } from './reviewSnapshot.js'
|
|
13
|
+
// @ts-expect-error Shared browser/server scoring keeps graph summary counts aligned with review rows.
|
|
14
|
+
import { evalReviewState } from '../../spec-dashboard/src/reviewFilters.js'
|
|
11
15
|
|
|
12
16
|
// a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
|
|
13
17
|
function resolveParent(path: string, byDir: Record<string, string>): string | null {
|
|
@@ -20,20 +24,22 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
|
|
|
20
24
|
return null
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
//
|
|
24
|
-
// reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
|
|
25
|
-
// annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
|
|
26
|
-
// degradation no error would surface; the field-preservation unit test pins this contract.
|
|
27
|
+
// The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
|
|
27
28
|
export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
|
|
28
29
|
const seen = new Set<string>()
|
|
29
30
|
return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
export function nodeEvalSummary(scenarios: { name: string }[], readings: any[]) {
|
|
34
|
+
type State = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty'
|
|
35
|
+
const latest = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]))
|
|
36
|
+
const summary = { total: scenarios.length, pass: 0, fail: 0, stalePass: 0, staleFail: 0, empty: 0 }
|
|
37
|
+
for (const scenario of scenarios) {
|
|
38
|
+
const reading = latest.get(scenario.name)
|
|
39
|
+
const state = (reading ? evalReviewState(reading) : 'empty') as State
|
|
40
|
+
summary[state]++
|
|
41
|
+
}
|
|
42
|
+
return summary
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
export async function buildBoard() {
|
|
@@ -104,10 +110,8 @@ export async function buildBoard() {
|
|
|
104
110
|
}),
|
|
105
111
|
...Object.values(ghostById),
|
|
106
112
|
]
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
|
|
110
|
-
// empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
|
|
113
|
+
// Reconcile Issues once. Full rows stay in the server-only review snapshot; graph nodes get counts and
|
|
114
|
+
// open identity only, enough for tile/stat/tree glances without reconstructing the list.
|
|
111
115
|
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
112
116
|
const merged = mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, nodes.map((n) => n.id))
|
|
113
117
|
// ONE board-level freshness stamp over EVERY issue thread (noded or nodeless, both stores):
|
|
@@ -128,37 +132,44 @@ export async function buildBoard() {
|
|
|
128
132
|
for (const n of nodes) {
|
|
129
133
|
const issues = issuesByNode[n.id]
|
|
130
134
|
if (!issues || !issues.length) continue
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
const open = issues.filter(isOpen)
|
|
136
|
+
n.reviewSummary = {
|
|
137
|
+
...(n.reviewSummary || {}),
|
|
138
|
+
issues: { open: open.length, closed: issues.length - open.length, openIds: open.map((issue) => issue.id) },
|
|
139
|
+
}
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
//
|
|
139
|
-
// the
|
|
140
|
-
// badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
|
|
141
|
-
// and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
|
|
142
|
-
// SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
|
|
143
|
-
// (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
|
|
144
|
-
// (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
|
|
142
|
+
// Reconcile current Evals once. Latest rows/declarations stay server-only for paged review; graph nodes
|
|
143
|
+
// receive the explicit per-state count projection and nothing row-shaped.
|
|
145
144
|
// evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-measurable nodes. The
|
|
146
145
|
// eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
|
|
147
146
|
const ynodes = await evalNodesAsync(root)
|
|
148
147
|
const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
|
|
149
|
-
await Promise.all(nodes.map(async (n) => {
|
|
148
|
+
const evalReviewNodes = (await Promise.all(nodes.map(async (n) => {
|
|
150
149
|
const tl = await evalTimeline(n.id, ectx)
|
|
151
|
-
if (tl.hasEvalFile)
|
|
152
|
-
|
|
150
|
+
if (!tl.hasEvalFile) return null
|
|
151
|
+
const latest = latestPerScenario(tl.readings)
|
|
152
|
+
n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(tl.scenarios, latest) }
|
|
153
|
+
return { id: n.id, hue: n.hue, scenarios: tl.scenarios, evals: latest, readings: tl.readings }
|
|
154
|
+
}))).filter((node): node is NonNullable<typeof node> => node !== null)
|
|
155
|
+
|
|
156
|
+
publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes })
|
|
153
157
|
|
|
154
158
|
const opsByPath: Record<string, any[]> = {}
|
|
155
159
|
opWts.forEach((w) => { opsByPath[w.path] = w.ops })
|
|
156
|
-
const
|
|
160
|
+
const evalProjections = sessionEvalProjections(sessions)
|
|
161
|
+
const sess = sessions.map((s) => ({
|
|
162
|
+
...s,
|
|
163
|
+
source: s.path,
|
|
164
|
+
ops: opsByPath[s.path] || [],
|
|
165
|
+
evalSummary: evalProjections.get(s.id),
|
|
166
|
+
}))
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
168
|
+
// One resolved identity projection feeds title, favicon, rail, and catalog compatibility. A worktree
|
|
169
|
+
// backend reads the actual served tree's branch config and identity; endpoint registration uses the
|
|
170
|
+
// same git toplevel, so a linked worktree occupies its own host slot instead of replacing main.
|
|
171
|
+
const identity = resolveProjectIdentity(root, root)
|
|
172
|
+
return { nodes, sessions: sess, identity, issuesStamp }
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
// @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
|
|
@@ -172,8 +183,14 @@ export async function buildBoard() {
|
|
|
172
183
|
// splice is byte-indistinguishable from a full rebuild whenever only session state moved.
|
|
173
184
|
export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>>): Promise<Awaited<ReturnType<typeof buildBoard>>> {
|
|
174
185
|
const sessions = await listSessions()
|
|
186
|
+
const evalProjections = sessionEvalProjections(sessions)
|
|
175
187
|
const opsByPath: Record<string, any[]> = {}
|
|
176
188
|
for (const s of prev.sessions) opsByPath[s.source] = s.ops
|
|
177
|
-
const sess = sessions.map((s) => ({
|
|
189
|
+
const sess = sessions.map((s) => ({
|
|
190
|
+
...s,
|
|
191
|
+
source: s.path,
|
|
192
|
+
ops: opsByPath[s.path] || [],
|
|
193
|
+
evalSummary: evalProjections.get(s.id),
|
|
194
|
+
}))
|
|
178
195
|
return { ...prev, sessions: sess }
|
|
179
196
|
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming'
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
|
-
import { watch, mkdirSync, readdirSync, readFileSync,
|
|
3
|
+
import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
|
|
4
4
|
import { join, dirname } from 'node:path'
|
|
5
5
|
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
6
|
import { hotSignature, warmSignature } from './sessions.js'
|
|
7
7
|
import { getBoard, invalidateBoard } from './graphCache.js'
|
|
8
8
|
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
|
+
import {
|
|
10
|
+
holdSessionEvalProjectionObserver,
|
|
11
|
+
invalidateSessionEvalProjections,
|
|
12
|
+
releaseSessionEvalProjectionObserver,
|
|
13
|
+
setSessionEvalProjectionNotify,
|
|
14
|
+
} from '../../spec-eval/src/sessioneval.js'
|
|
9
15
|
|
|
10
16
|
// @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
|
|
11
17
|
// plain mode it gets a bare `graph-changed` and refetches /api/graph (the legacy protocol, kept verbatim
|
|
@@ -21,7 +27,7 @@ import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
|
21
27
|
// Sources: (1) fs.watch on the per-user session store — every lifecycle transition lands as a
|
|
22
28
|
// sessions/<id>/session.json write → 'sessions'; (2) fs.watch on the shared git dir's refs (+
|
|
23
29
|
// packed-refs/HEAD) — a commit/merge moves a ref, reshaping the tree → 'full'; (3) fs.watch on the git
|
|
24
|
-
// worktree REGISTRY (+ each live worktree
|
|
30
|
+
// worktree REGISTRY (+ each live worktree root and gitdir index) — dirty source/spec/sidecar/rename/stage → 'full';
|
|
25
31
|
// (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
|
|
26
32
|
// a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
|
|
27
33
|
// PATROL that invalidates FULL, rebuilds and diffs — the self-heal authority that catches whatever every
|
|
@@ -29,6 +35,7 @@ import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
|
29
35
|
// without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
|
|
30
36
|
|
|
31
37
|
type Scope = 'sessions' | 'full'
|
|
38
|
+
type EvalTarget = 'all' | { id?: string; path?: string }
|
|
32
39
|
type Notify = () => void
|
|
33
40
|
type Frame = { event: string; data: string }
|
|
34
41
|
type DeltaSend = (frame: Frame) => void
|
|
@@ -94,9 +101,12 @@ async function rebuildAndBroadcast(): Promise<void> {
|
|
|
94
101
|
// guaranteed win: ship the patch only when it actually beats the snapshot
|
|
95
102
|
if (deltaData.length < fullFrame.data.length) frame = { event: 'graph-delta', data: deltaData }
|
|
96
103
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
// the anchor is only meaningful while a delta subscriber holds the chain live: with none left,
|
|
105
|
+
// nothing rebuilds on change, so a cached anchor would silently age into a stale first frame for the
|
|
106
|
+
// NEXT era's subscriber (issue #70). A build that completes after the last unsub caches nothing
|
|
107
|
+
// (stopSourcesIfIdle cleared the anchor; leaving lastTag/lastUnits stale-cleared is consistent —
|
|
108
|
+
// rebuilds only run while delta subscribers exist, so nothing chains from them meanwhile).
|
|
109
|
+
if (deltaSubs.size) { lastUnits = ok ? units : null; lastTag = tag; lastFullFrame = fullFrame }
|
|
100
110
|
for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
|
|
101
111
|
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
102
112
|
// ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
|
|
@@ -117,7 +127,10 @@ async function rebuildAndBroadcast(): Promise<void> {
|
|
|
117
127
|
// carries its change SCOPE; the window accumulates the MAX ([[graph-cache]] escalates none→sessions→full).
|
|
118
128
|
// With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
|
|
119
129
|
// tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
|
|
120
|
-
function fireChanged(scope: Scope = 'full'): void {
|
|
130
|
+
function fireChanged(scope: Scope = 'full', evalTarget?: EvalTarget): void {
|
|
131
|
+
// Advance eval input generations BEFORE invalidating/building the board, so the first frame caused by an
|
|
132
|
+
// input event is `updating(lastKnown)`. Summary completion calls this function without a target.
|
|
133
|
+
if (evalTarget) invalidateSessionEvalProjections(evalTarget)
|
|
121
134
|
pendingScope = maxScope(pendingScope, scope)
|
|
122
135
|
// invalidate the route's board cache ([[graph-cache]]) on EVERY change signal, at the accumulated scope,
|
|
123
136
|
// before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
|
|
@@ -143,7 +156,11 @@ function fireChanged(scope: Scope = 'full'): void {
|
|
|
143
156
|
// source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
|
|
144
157
|
// (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
|
|
145
158
|
// debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
|
|
146
|
-
export const notifyBoardChanged = (scope: Scope = 'full'): void =>
|
|
159
|
+
export const notifyBoardChanged = (scope: Scope = 'full'): void =>
|
|
160
|
+
fireChanged(scope, scope === 'full' ? 'all' : undefined)
|
|
161
|
+
|
|
162
|
+
// Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
|
|
163
|
+
setSessionEvalProjectionNotify(() => fireChanged('sessions'))
|
|
147
164
|
|
|
148
165
|
// ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
|
|
149
166
|
let watcher: FSWatcher | null = null
|
|
@@ -157,57 +174,268 @@ function ensureWatcher(): void {
|
|
|
157
174
|
|
|
158
175
|
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
|
|
159
176
|
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
160
|
-
// for packed-refs rewrites and HEAD flips.
|
|
177
|
+
// for packed-refs rewrites and HEAD flips. Ordinary graph units still have the patrol; eval projections are
|
|
178
|
+
// observer-held across a failure and only become current after a replacement watch authorizes a rescan.
|
|
161
179
|
let refsWatchers: FSWatcher[] | null = null
|
|
162
|
-
|
|
180
|
+
const REFS_OBSERVER = 'graph:refs'
|
|
181
|
+
|
|
182
|
+
export function watchSessionEvalRefs(
|
|
183
|
+
common: string,
|
|
184
|
+
onInput: () => void,
|
|
185
|
+
onFailure: () => void,
|
|
186
|
+
): FSWatcher[] {
|
|
187
|
+
const attached: FSWatcher[] = []
|
|
188
|
+
let failed = false
|
|
189
|
+
const close = () => {
|
|
190
|
+
for (const watcher of attached) { try { watcher.close() } catch { /* already gone */ } }
|
|
191
|
+
}
|
|
192
|
+
const fail = () => {
|
|
193
|
+
if (failed) return
|
|
194
|
+
failed = true
|
|
195
|
+
close()
|
|
196
|
+
onFailure()
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
attached.push(watch(join(common, 'refs'), { recursive: true }, (_event, file) => {
|
|
200
|
+
if (file == null) { fail(); return }
|
|
201
|
+
onInput()
|
|
202
|
+
}))
|
|
203
|
+
attached.push(watch(common, (_event, file) => {
|
|
204
|
+
if (file == null) { fail(); return }
|
|
205
|
+
if (file === 'packed-refs' || file === 'HEAD') onInput()
|
|
206
|
+
}))
|
|
207
|
+
for (const watcher of attached) watcher.on('error', fail)
|
|
208
|
+
return attached
|
|
209
|
+
} catch (error) {
|
|
210
|
+
close()
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function refsWatcherFailed(): void {
|
|
216
|
+
refsWatchers = null
|
|
217
|
+
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
218
|
+
setImmediate(() => ensureRefsWatcher(false))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function ensureRefsWatcher(retry = true): void {
|
|
163
222
|
if (refsWatchers) return
|
|
164
|
-
if (isDisabled('refs'))
|
|
165
|
-
|
|
223
|
+
if (isDisabled('refs')) {
|
|
224
|
+
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
225
|
+
return
|
|
226
|
+
}
|
|
166
227
|
try {
|
|
167
228
|
const common = gitCommonDir()
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
} catch {
|
|
229
|
+
refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full', 'all'), refsWatcherFailed)
|
|
230
|
+
if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
|
|
231
|
+
} catch {
|
|
232
|
+
refsWatchers = null
|
|
233
|
+
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
234
|
+
if (retry) setImmediate(() => ensureRefsWatcher(false))
|
|
235
|
+
}
|
|
171
236
|
}
|
|
172
237
|
|
|
173
|
-
// ---- event source 3:
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// worktree, and on each event we RECONCILE the per-worktree `.spec` watchers (resolving each entry's tree
|
|
178
|
-
// via its `gitdir` file). Everything best-effort: a failed watch just leaves that path to the patrol.
|
|
238
|
+
// ---- event source 3: worktree registry + working roots + per-worktree indexes → 'full' ----
|
|
239
|
+
// Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each registry row
|
|
240
|
+
// therefore owns a recursive working-root watcher plus a non-recursive watcher on git's worktree metadata
|
|
241
|
+
// dir (`index`). A delivered event advances that worktree's eval generation before the graph rebuild.
|
|
179
242
|
let registryWatcher: FSWatcher | null = null
|
|
180
|
-
|
|
243
|
+
type WorktreeWatch = { path: string; root: FSWatcher; index: FSWatcher }
|
|
244
|
+
const worktreeWatchers = new Map<string, WorktreeWatch>()
|
|
245
|
+
const worktreeRetryAttempted = new Set<string>()
|
|
246
|
+
const worktreeRetryCount = new Map<string, number>()
|
|
247
|
+
const worktreeObserver = (name: string): string => `graph:worktree:${name}`
|
|
248
|
+
|
|
249
|
+
export function scheduleWorktreeResubscribe(
|
|
250
|
+
name: string,
|
|
251
|
+
attempted: Set<string>,
|
|
252
|
+
retry: () => void,
|
|
253
|
+
delayMs = 0,
|
|
254
|
+
): boolean {
|
|
255
|
+
if (attempted.has(name)) return false
|
|
256
|
+
attempted.add(name)
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
attempted.delete(name)
|
|
259
|
+
retry()
|
|
260
|
+
}, delayMs)
|
|
261
|
+
timer.unref?.()
|
|
262
|
+
return true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function scheduleWorktreeRetry(name: string): boolean {
|
|
266
|
+
if (worktreeRetryAttempted.has(name)) return false
|
|
267
|
+
const attempt = (worktreeRetryCount.get(name) ?? 0) + 1
|
|
268
|
+
worktreeRetryCount.set(name, attempt)
|
|
269
|
+
const delayMs = Math.min(1_000, 25 * (2 ** Math.min(attempt - 1, 5)))
|
|
270
|
+
return scheduleWorktreeResubscribe(name, worktreeRetryAttempted, reconcileWorktrees, delayMs)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const ignoredWorktreePath = (file: string): boolean =>
|
|
274
|
+
file === '.git' || file.startsWith('.git/') || file === 'node_modules' || file.startsWith('node_modules/')
|
|
275
|
+
|
|
276
|
+
export function watchSessionEvalWorktree(
|
|
277
|
+
wtPath: string,
|
|
278
|
+
gitDir: string,
|
|
279
|
+
onInput: () => void,
|
|
280
|
+
onFailure: () => void,
|
|
281
|
+
): { root: FSWatcher; index: FSWatcher } {
|
|
282
|
+
let failed = false
|
|
283
|
+
let root: FSWatcher | null = null
|
|
284
|
+
let index: FSWatcher | null = null
|
|
285
|
+
const fail = () => {
|
|
286
|
+
if (failed) return
|
|
287
|
+
failed = true
|
|
288
|
+
try { root?.close() } catch { /* already gone */ }
|
|
289
|
+
try { index?.close() } catch { /* already gone */ }
|
|
290
|
+
onFailure()
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
root = watch(wtPath, { recursive: true }, (_event, filename) => {
|
|
294
|
+
if (filename == null) { fail(); return }
|
|
295
|
+
const file = String(filename)
|
|
296
|
+
if (!ignoredWorktreePath(file)) onInput()
|
|
297
|
+
})
|
|
298
|
+
index = watch(gitDir, (_event, filename) => {
|
|
299
|
+
if (filename == null) { fail(); return }
|
|
300
|
+
if (String(filename) === 'index') onInput()
|
|
301
|
+
})
|
|
302
|
+
root.on('error', fail)
|
|
303
|
+
index.on('error', fail)
|
|
304
|
+
return { root, index }
|
|
305
|
+
} catch (error) {
|
|
306
|
+
fail()
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function dropWorktreeWatcher(name: string): WorktreeWatch | null {
|
|
312
|
+
const row = worktreeWatchers.get(name)
|
|
313
|
+
if (!row) return null
|
|
314
|
+
worktreeWatchers.delete(name)
|
|
315
|
+
try { row.root.close() } catch { /* already gone */ }
|
|
316
|
+
try { row.index.close() } catch { /* already gone */ }
|
|
317
|
+
return row
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function watcherFailed(name: string, path: string): void {
|
|
321
|
+
// Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and immediately
|
|
322
|
+
// resubscribe; the authorized summary build is the one authoritative rescan, never a periodic sweep.
|
|
323
|
+
dropWorktreeWatcher(name)
|
|
324
|
+
if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path })) fireChanged('full')
|
|
325
|
+
scheduleWorktreeRetry(name)
|
|
326
|
+
}
|
|
327
|
+
|
|
181
328
|
function reconcileWorktrees(): void {
|
|
182
329
|
let dir: string
|
|
183
330
|
try { dir = join(gitCommonDir(), 'worktrees') } catch { return }
|
|
184
331
|
let ents: import('node:fs').Dirent[] = []
|
|
185
332
|
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
|
|
186
333
|
const live = new Set<string>()
|
|
334
|
+
let released = false
|
|
187
335
|
for (const e of ents) {
|
|
188
336
|
if (!e.isDirectory()) continue
|
|
189
337
|
live.add(e.name)
|
|
190
|
-
if (
|
|
338
|
+
if (worktreeWatchers.has(e.name) || worktreeRetryAttempted.has(e.name)) continue
|
|
191
339
|
try {
|
|
192
340
|
// the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
|
|
193
341
|
const wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim())
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
342
|
+
const { root, index } = watchSessionEvalWorktree(
|
|
343
|
+
wtPath,
|
|
344
|
+
join(dir, e.name),
|
|
345
|
+
() => fireChanged('full', { path: wtPath }),
|
|
346
|
+
() => watcherFailed(e.name, wtPath),
|
|
347
|
+
)
|
|
348
|
+
const row = { path: wtPath, root, index }
|
|
349
|
+
worktreeWatchers.set(e.name, row)
|
|
350
|
+
worktreeRetryAttempted.delete(e.name)
|
|
351
|
+
worktreeRetryCount.delete(e.name)
|
|
352
|
+
// The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
|
|
353
|
+
// so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
|
|
354
|
+
if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name))) fireChanged('full')
|
|
355
|
+
} catch {
|
|
356
|
+
// An attach failure is observable: mark unknown/full now. The next registry change or explicit graph
|
|
357
|
+
// source setup retries; no patrol is allowed to call the eval projection current.
|
|
358
|
+
let path: string | null = null
|
|
359
|
+
try { path = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { /* broken row */ }
|
|
360
|
+
if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), path ? { path } : 'all')) fireChanged('full')
|
|
361
|
+
scheduleWorktreeRetry(e.name)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const name of worktreeWatchers.keys()) if (!live.has(name)) {
|
|
365
|
+
dropWorktreeWatcher(name)
|
|
366
|
+
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
367
|
+
}
|
|
368
|
+
for (const name of worktreeRetryCount.keys()) if (!live.has(name)) {
|
|
369
|
+
worktreeRetryAttempted.delete(name)
|
|
370
|
+
worktreeRetryCount.delete(name)
|
|
371
|
+
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
197
372
|
}
|
|
198
|
-
|
|
373
|
+
if (released) fireChanged('full')
|
|
374
|
+
}
|
|
375
|
+
const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
|
|
376
|
+
|
|
377
|
+
export function watchSessionEvalRegistry(
|
|
378
|
+
dir: string,
|
|
379
|
+
onInput: () => void,
|
|
380
|
+
onFailure: () => void,
|
|
381
|
+
): FSWatcher {
|
|
382
|
+
let watcher: FSWatcher | null = null
|
|
383
|
+
let failed = false
|
|
384
|
+
const fail = () => {
|
|
385
|
+
if (failed) return
|
|
386
|
+
failed = true
|
|
387
|
+
try { watcher?.close() } catch { /* already gone */ }
|
|
388
|
+
onFailure()
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
watcher = watch(dir, (_event, file) => {
|
|
392
|
+
if (file == null) { fail(); return }
|
|
393
|
+
onInput()
|
|
394
|
+
})
|
|
395
|
+
watcher.on('error', fail)
|
|
396
|
+
return watcher
|
|
397
|
+
} catch (error) {
|
|
398
|
+
try { watcher?.close() } catch { /* partial attach */ }
|
|
399
|
+
throw error
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function registryWatcherFailed(): void {
|
|
404
|
+
registryWatcher = null
|
|
405
|
+
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
406
|
+
setImmediate(() => ensureWorktreeRegistry(false))
|
|
199
407
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (
|
|
408
|
+
|
|
409
|
+
function ensureWorktreeRegistry(retry = true): void {
|
|
410
|
+
if (registryWatcher) { reconcileWorktrees(); return }
|
|
411
|
+
if (isDisabled('worktrees')) {
|
|
412
|
+
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
413
|
+
return
|
|
414
|
+
}
|
|
203
415
|
try {
|
|
204
416
|
const dir = join(gitCommonDir(), 'worktrees')
|
|
205
417
|
try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ }
|
|
206
418
|
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
207
419
|
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
208
|
-
registryWatcher =
|
|
209
|
-
|
|
420
|
+
registryWatcher = watchSessionEvalRegistry(dir, () => {
|
|
421
|
+
reconcileWorktrees()
|
|
422
|
+
fireChanged('full', 'all')
|
|
423
|
+
}, registryWatcherFailed)
|
|
424
|
+
} catch {
|
|
425
|
+
registryWatcher = null
|
|
426
|
+
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
427
|
+
if (retry) setImmediate(() => ensureWorktreeRegistry(false))
|
|
428
|
+
}
|
|
210
429
|
reconcileWorktrees() // attach for the worktrees that already exist when the source starts
|
|
430
|
+
if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)) fireChanged('full')
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Attach the canonical filesystem sources before an HTTP snapshot starts summary work. This closes the
|
|
434
|
+
// request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur.
|
|
435
|
+
export function ensureBoardFileWatchers(): void {
|
|
436
|
+
ensureWatcher()
|
|
437
|
+
ensureRefsWatcher()
|
|
438
|
+
ensureWorktreeRegistry()
|
|
211
439
|
}
|
|
212
440
|
|
|
213
441
|
// ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
|
|
@@ -245,6 +473,11 @@ function ensureColdTick(): void {
|
|
|
245
473
|
}
|
|
246
474
|
|
|
247
475
|
function stopSourcesIfIdle(): void {
|
|
476
|
+
// the delta anchor dies with its era's last subscriber: past this point changes invalidate the cache but
|
|
477
|
+
// never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
|
|
478
|
+
// first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
|
|
479
|
+
// client's recovery lanes can latch each other out — issue #70). A new era opens on a fresh build instead.
|
|
480
|
+
if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null }
|
|
248
481
|
if (plainSubs.size + deltaSubs.size > 0) return
|
|
249
482
|
if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
|
|
250
483
|
if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
|
|
@@ -257,9 +490,7 @@ function stopSourcesIfIdle(): void {
|
|
|
257
490
|
// chain re-anchors with no client-side repair logic.
|
|
258
491
|
export function boardStream(c: Context) {
|
|
259
492
|
const delta = c.req.query('mode') === 'delta'
|
|
260
|
-
|
|
261
|
-
ensureRefsWatcher()
|
|
262
|
-
ensureWorktreeRegistry()
|
|
493
|
+
ensureBoardFileWatchers()
|
|
263
494
|
return streamSSE(c, async (stream) => {
|
|
264
495
|
let aborted = false
|
|
265
496
|
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|