spexcode 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { watch, mkdirSync, readdirSync, readFileSync, existsSync, type FSWatcher } from 'node:fs'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
+
import { hotSignature, warmSignature } from './sessions.js'
|
|
7
|
+
import { getBoard, invalidateBoard } from './graphCache.js'
|
|
8
|
+
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
|
+
|
|
10
|
+
// @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
|
|
11
|
+
// plain mode it gets a bare `graph-changed` and refetches /api/graph (the legacy protocol, kept verbatim
|
|
12
|
+
// for old clients); in DELTA mode (`?mode=delta`) the server itself rebuilds on change and streams the
|
|
13
|
+
// hash-chained patch ([[graph-delta]]): a `graph-full {to, graph}` on connect, then `graph-delta
|
|
14
|
+
// {from, to, set, del}` per change — a few KB against the ~600KB snapshot, with a full-snapshot send
|
|
15
|
+
// whenever the patch wouldn't win (bigger than the board, or the unit decomposition's id-uniqueness
|
|
16
|
+
// precondition failed), so a delta subscriber is NEVER worse off than a full refetch.
|
|
17
|
+
//
|
|
18
|
+
// Every source carries the DOMAIN of the change it saw — 'sessions' (only the session rows moved) or 'full'
|
|
19
|
+
// (anything could have) — and fireChanged funnels them into ONE debounced pipeline, escalating to the max
|
|
20
|
+
// scope seen in the window so the cache can splice sessions instead of rebuilding whole ([[graph-cache]]).
|
|
21
|
+
// Sources: (1) fs.watch on the per-user session store — every lifecycle transition lands as a
|
|
22
|
+
// sessions/<id>/session.json write → 'sessions'; (2) fs.watch on the shared git dir's refs (+
|
|
23
|
+
// 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's `.spec`) — an uncommitted spec edit in a linked worktree → 'full';
|
|
25
|
+
// (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
|
|
26
|
+
// a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
|
|
27
|
+
// PATROL that invalidates FULL, rebuilds and diffs — the self-heal authority that catches whatever every
|
|
28
|
+
// leaf watcher missed (and is loud when it has to: see the repair accounting below) → 'full'. Plain mode
|
|
29
|
+
// without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
|
|
30
|
+
|
|
31
|
+
type Scope = 'sessions' | 'full'
|
|
32
|
+
type Notify = () => void
|
|
33
|
+
type Frame = { event: string; data: string }
|
|
34
|
+
type DeltaSend = (frame: Frame) => void
|
|
35
|
+
const plainSubs = new Set<Notify>()
|
|
36
|
+
const deltaSubs = new Set<DeltaSend>()
|
|
37
|
+
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
38
|
+
let pendingScope: Scope | null = null // the MAX change scope accumulated across the current debounce window
|
|
39
|
+
const maxScope = (a: Scope | null, b: Scope): Scope => (a === 'full' || b === 'full' ? 'full' : 'sessions')
|
|
40
|
+
|
|
41
|
+
// under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
|
|
42
|
+
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
43
|
+
// the set of trigger tags accrued SINCE THE LAST BROADCAST — each fireChanged adds its scope, the cold tick
|
|
44
|
+
// adds 'patrol'. Cleared on every broadcast. Its job: prove WHO caused a broadcast, so a change that only
|
|
45
|
+
// the patrol saw (tag set === {'patrol'}) is flagged as a repair — some leaf watcher was blind.
|
|
46
|
+
const triggerTags = new Set<string>()
|
|
47
|
+
|
|
48
|
+
// watchers a test can amputate to prove the patrol still heals the graph ([[graph-stream]]): a CSV of
|
|
49
|
+
// store,refs,worktrees makes the matching ensure* a no-op (with a one-time warning), so a change on that
|
|
50
|
+
// path reaches subscribers ONLY via the cold-tick patrol — the missing-watcher scenario, on demand.
|
|
51
|
+
const DISABLED = new Set((process.env.SPEXCODE_DISABLE_WATCHERS || '').split(',').map((s) => s.trim()).filter(Boolean))
|
|
52
|
+
const warnedDisabled = new Set<string>()
|
|
53
|
+
function isDisabled(name: string): boolean {
|
|
54
|
+
if (!DISABLED.has(name)) return false
|
|
55
|
+
if (!warnedDisabled.has(name)) { warnedDisabled.add(name); console.warn(`spec-cli: graph watcher '${name}' disabled via SPEXCODE_DISABLE_WATCHERS — the cold-tick patrol must cover it`) }
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
|
|
60
|
+
// last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
|
|
61
|
+
// subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
|
|
62
|
+
// failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
|
|
63
|
+
let lastUnits: Units | null = null
|
|
64
|
+
let lastTag = ''
|
|
65
|
+
let lastFullFrame: Frame | null = null
|
|
66
|
+
let building = false
|
|
67
|
+
let dirty = false
|
|
68
|
+
|
|
69
|
+
async function rebuildAndBroadcast(): Promise<void> {
|
|
70
|
+
if (building) { dirty = true; return }
|
|
71
|
+
building = true
|
|
72
|
+
try {
|
|
73
|
+
do {
|
|
74
|
+
dirty = false
|
|
75
|
+
let board: unknown
|
|
76
|
+
// share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
|
|
77
|
+
// cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
|
|
78
|
+
// already started).
|
|
79
|
+
const t0 = Date.now()
|
|
80
|
+
try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
|
|
81
|
+
const buildMs = Date.now() - t0
|
|
82
|
+
const boardJson = JSON.stringify(board)
|
|
83
|
+
const { units, ok } = unitize(board as Record<string, unknown>)
|
|
84
|
+
const tag = tagOf(units)
|
|
85
|
+
if (tag === lastTag) continue
|
|
86
|
+
// the changed unit keys — computed against the prior anchor when we have one (a first paint has no
|
|
87
|
+
// anchor, so no repair claim can be made against it).
|
|
88
|
+
const changedKeys = lastUnits ? (() => { const { set, del } = diffUnits(lastUnits, units); return [...Object.keys(set), ...del] })() : []
|
|
89
|
+
const fullFrame: Frame = { event: 'graph-full', data: `{"to":"${tag}","graph":${boardJson}}` }
|
|
90
|
+
let frame = fullFrame
|
|
91
|
+
if (lastUnits && ok) {
|
|
92
|
+
const { set, del } = diffUnits(lastUnits, units)
|
|
93
|
+
const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
|
|
94
|
+
// guaranteed win: ship the patch only when it actually beats the snapshot
|
|
95
|
+
if (deltaData.length < fullFrame.data.length) frame = { event: 'graph-delta', data: deltaData }
|
|
96
|
+
}
|
|
97
|
+
lastUnits = ok ? units : null
|
|
98
|
+
lastTag = tag
|
|
99
|
+
lastFullFrame = fullFrame
|
|
100
|
+
for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
|
|
101
|
+
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
102
|
+
// ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
|
|
103
|
+
// means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
|
|
104
|
+
// it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
|
|
105
|
+
// broadcast logs its changed keys + triggers + build ms.
|
|
106
|
+
const tags = [...triggerTags]
|
|
107
|
+
if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
|
|
108
|
+
console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`)
|
|
109
|
+
if (DEBUG)
|
|
110
|
+
console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`)
|
|
111
|
+
triggerTags.clear()
|
|
112
|
+
} while (dirty)
|
|
113
|
+
} finally { building = false }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// a merge/launch/close touches several record files at once; collapse the burst into ONE signal. Each call
|
|
117
|
+
// carries its change SCOPE; the window accumulates the MAX ([[graph-cache]] escalates none→sessions→full).
|
|
118
|
+
// With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
|
|
119
|
+
// tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
|
|
120
|
+
function fireChanged(scope: Scope = 'full'): void {
|
|
121
|
+
pendingScope = maxScope(pendingScope, scope)
|
|
122
|
+
// invalidate the route's board cache ([[graph-cache]]) on EVERY change signal, at the accumulated scope,
|
|
123
|
+
// before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
|
|
124
|
+
// still see fresh data on its next poll, and a delta rebuild below re-reads the same now-stale cache.
|
|
125
|
+
invalidateBoard(pendingScope)
|
|
126
|
+
triggerTags.add(scope)
|
|
127
|
+
// DEBOUNCE = 25ms. Real fs-event bursts (a merge touching many records) were MEASURED to span 0–5ms, so a
|
|
128
|
+
// 25ms window collapses them with room to spare while shaving ~125ms off the old 150ms lag; anything
|
|
129
|
+
// wider than the window is coalesced anyway by the in-flight build's dirty-rerun loop, which is the real
|
|
130
|
+
// burst absorber. So the debounce is a micro-collapse, not the coalescer.
|
|
131
|
+
if (debounce) return
|
|
132
|
+
debounce = setTimeout(() => {
|
|
133
|
+
debounce = null
|
|
134
|
+
pendingScope = null
|
|
135
|
+
if (deltaSubs.size) void rebuildAndBroadcast()
|
|
136
|
+
else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
|
|
137
|
+
}, 25)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
141
|
+
// for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
|
|
142
|
+
// session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
|
|
143
|
+
// source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
|
|
144
|
+
// (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
|
|
145
|
+
// debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
|
|
146
|
+
export const notifyBoardChanged = (scope: Scope = 'full'): void => fireChanged(scope)
|
|
147
|
+
|
|
148
|
+
// ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
|
|
149
|
+
let watcher: FSWatcher | null = null
|
|
150
|
+
function ensureWatcher(): void {
|
|
151
|
+
if (watcher) return
|
|
152
|
+
if (isDisabled('store')) return
|
|
153
|
+
const root = sessionsRoot()
|
|
154
|
+
try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
|
|
155
|
+
try { watcher = watch(root, { recursive: true }, () => fireChanged('sessions')) } catch { watcher = null }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
|
|
159
|
+
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
160
|
+
// for packed-refs rewrites and HEAD flips. Best-effort like every source: no watch → the cold tick covers.
|
|
161
|
+
let refsWatchers: FSWatcher[] | null = null
|
|
162
|
+
function ensureRefsWatcher(): void {
|
|
163
|
+
if (refsWatchers) return
|
|
164
|
+
if (isDisabled('refs')) return
|
|
165
|
+
refsWatchers = []
|
|
166
|
+
try {
|
|
167
|
+
const common = gitCommonDir()
|
|
168
|
+
try { refsWatchers.push(watch(join(common, 'refs'), { recursive: true }, () => fireChanged('full'))) } catch { /* loose refs unwatched */ }
|
|
169
|
+
try { refsWatchers.push(watch(common, (_e, f) => { if (f === 'packed-refs' || f === 'HEAD') fireChanged('full') })) } catch { /* packed refs unwatched */ }
|
|
170
|
+
} catch { /* not a repo? the cold tick still covers */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- event source 3: the git worktree REGISTRY + each live worktree's `.spec` → 'full' ----
|
|
174
|
+
// An UNCOMMITTED spec edit in a linked worktree moves no ref and writes no session record, so neither
|
|
175
|
+
// source 1 nor 2 sees it — only a watch on the worktree's own `.spec` does. The registry (`<git-common>/
|
|
176
|
+
// worktrees/<name>/`) is the index of live worktrees; watching it non-recursively catches add/remove of a
|
|
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.
|
|
179
|
+
let registryWatcher: FSWatcher | null = null
|
|
180
|
+
const specWatchers = new Map<string, FSWatcher>() // registry entry name → recursive watch on <worktree>/.spec
|
|
181
|
+
function reconcileWorktrees(): void {
|
|
182
|
+
let dir: string
|
|
183
|
+
try { dir = join(gitCommonDir(), 'worktrees') } catch { return }
|
|
184
|
+
let ents: import('node:fs').Dirent[] = []
|
|
185
|
+
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
|
|
186
|
+
const live = new Set<string>()
|
|
187
|
+
for (const e of ents) {
|
|
188
|
+
if (!e.isDirectory()) continue
|
|
189
|
+
live.add(e.name)
|
|
190
|
+
if (specWatchers.has(e.name)) continue
|
|
191
|
+
try {
|
|
192
|
+
// the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
|
|
193
|
+
const wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim())
|
|
194
|
+
const specDir = join(wtPath, '.spec')
|
|
195
|
+
if (existsSync(specDir)) specWatchers.set(e.name, watch(specDir, { recursive: true }, () => fireChanged('full')))
|
|
196
|
+
} catch { /* best-effort; the patrol covers an unwatched worktree */ }
|
|
197
|
+
}
|
|
198
|
+
for (const [name, w] of [...specWatchers]) if (!live.has(name)) { try { w.close() } catch { /* already gone */ } ; specWatchers.delete(name) }
|
|
199
|
+
}
|
|
200
|
+
function ensureWorktreeRegistry(): void {
|
|
201
|
+
if (registryWatcher) return
|
|
202
|
+
if (isDisabled('worktrees')) return
|
|
203
|
+
try {
|
|
204
|
+
const dir = join(gitCommonDir(), 'worktrees')
|
|
205
|
+
try { mkdirSync(dir, { recursive: true }) } catch { /* best-effort */ }
|
|
206
|
+
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
207
|
+
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
208
|
+
registryWatcher = watch(dir, () => { reconcileWorktrees(); fireChanged('full') })
|
|
209
|
+
} catch { registryWatcher = null }
|
|
210
|
+
reconcileWorktrees() // attach for the worktrees that already exist when the source starts
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
|
|
214
|
+
// The signals a store watch can't see are tmux-derived, and they split by cost ([[sessions]]): a HOT 100ms
|
|
215
|
+
// poll of a cheap syscall-only fingerprint (a socket dying, a listener wedging) and a WARM 1s poll of the
|
|
216
|
+
// pane-title self-summaries (a headline change is a tmux round-trip, too dear at 100ms). Both fire 'sessions'.
|
|
217
|
+
let hotPoller: ReturnType<typeof setInterval> | null = null
|
|
218
|
+
let warmPoller: ReturnType<typeof setInterval> | null = null
|
|
219
|
+
let lastHot = ''
|
|
220
|
+
let lastWarm = ''
|
|
221
|
+
function ensurePollers(): void {
|
|
222
|
+
if (!hotPoller) hotPoller = setInterval(() => {
|
|
223
|
+
void hotSignature().then((sig) => { if (sig !== lastHot) { lastHot = sig; fireChanged('sessions') } }).catch(() => {})
|
|
224
|
+
}, 100)
|
|
225
|
+
if (!warmPoller) warmPoller = setInterval(() => {
|
|
226
|
+
void warmSignature().then((sig) => { if (sig !== lastWarm) { lastWarm = sig; fireChanged('sessions') } }).catch(() => {})
|
|
227
|
+
}, 1000)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
|
|
231
|
+
// poll, AND the self-heal authority. Rebuild+diff on a relaxed timer so what NO watcher saw (an uncommitted
|
|
232
|
+
// worktree spec edit a registry watch missed, a forge issue refresh) still lands. It INVALIDATES FULL first
|
|
233
|
+
// — otherwise getBoard() serves the stale cache and the patrol is a no-op (the real bug this fixes) — and
|
|
234
|
+
// tags the window 'patrol' so the repair accounting can flag a change only it caught. Delta-gated: plain-only
|
|
235
|
+
// clients keep their own client-side fallback, so without delta subscribers this must not burn builds.
|
|
236
|
+
let coldTick: ReturnType<typeof setInterval> | null = null
|
|
237
|
+
function ensureColdTick(): void {
|
|
238
|
+
if (coldTick) return
|
|
239
|
+
coldTick = setInterval(() => {
|
|
240
|
+
if (!deltaSubs.size) return
|
|
241
|
+
invalidateBoard('full')
|
|
242
|
+
triggerTags.add('patrol')
|
|
243
|
+
void rebuildAndBroadcast()
|
|
244
|
+
}, 15000)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stopSourcesIfIdle(): void {
|
|
248
|
+
if (plainSubs.size + deltaSubs.size > 0) return
|
|
249
|
+
if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
|
|
250
|
+
if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
|
|
251
|
+
if (coldTick) { clearInterval(coldTick); coldTick = null }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// GET /api/graph/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
|
|
255
|
+
// idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
|
|
256
|
+
// auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
|
|
257
|
+
// chain re-anchors with no client-side repair logic.
|
|
258
|
+
export function boardStream(c: Context) {
|
|
259
|
+
const delta = c.req.query('mode') === 'delta'
|
|
260
|
+
ensureWatcher()
|
|
261
|
+
ensureRefsWatcher()
|
|
262
|
+
ensureWorktreeRegistry()
|
|
263
|
+
return streamSSE(c, async (stream) => {
|
|
264
|
+
let aborted = false
|
|
265
|
+
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|
|
266
|
+
const notify: Notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => {}) }
|
|
267
|
+
if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
|
|
268
|
+
ensurePollers()
|
|
269
|
+
const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
|
|
270
|
+
stream.onAbort(() => { aborted = true; unsub() })
|
|
271
|
+
try {
|
|
272
|
+
await stream.writeSSE({ event: 'ready', data: 'x' })
|
|
273
|
+
if (delta) {
|
|
274
|
+
// seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
|
|
275
|
+
// then a fire so a connect during a quiet stretch converges to truly-current within one build.
|
|
276
|
+
if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
|
|
277
|
+
else void rebuildAndBroadcast()
|
|
278
|
+
}
|
|
279
|
+
while (!aborted) {
|
|
280
|
+
// ping every 10s — the client's heartbeat contract is 2.5× this window ([[graph-stream]]), so a
|
|
281
|
+
// silent-death gap is caught inside one client watchdog interval, and idle proxies never time out.
|
|
282
|
+
await stream.sleep(10000)
|
|
283
|
+
if (aborted) break
|
|
284
|
+
await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
285
|
+
}
|
|
286
|
+
} finally { unsub() }
|
|
287
|
+
})
|
|
288
|
+
}
|