spexcode 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { streamSSE } from 'hono/streaming'
|
|
2
2
|
import type { Context } from 'hono'
|
|
3
3
|
import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
|
|
4
|
-
import { join, dirname } from 'node:path'
|
|
4
|
+
import { join, dirname, relative, resolve } from 'node:path'
|
|
5
5
|
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
-
import { hotSignature, warmSignature } from './sessions.js'
|
|
7
|
-
import { getBoard, invalidateBoard } from './graphCache.js'
|
|
6
|
+
import { hotSignature, warmSignature, listSessions } from './sessions.js'
|
|
7
|
+
import { getBoard, invalidateBoard, patrolBoard } from './graphCache.js'
|
|
8
8
|
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
9
|
import {
|
|
10
10
|
holdSessionEvalProjectionObserver,
|
|
11
11
|
invalidateSessionEvalProjections,
|
|
12
12
|
releaseSessionEvalProjectionObserver,
|
|
13
13
|
setSessionEvalProjectionNotify,
|
|
14
|
+
setSessionEvalProjectionWarmup,
|
|
14
15
|
} from '../../spec-eval/src/sessioneval.js'
|
|
15
16
|
|
|
16
17
|
// @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
|
|
@@ -30,15 +31,202 @@ import {
|
|
|
30
31
|
// worktree REGISTRY (+ each live worktree root and gitdir index) — dirty source/spec/sidecar/rename/stage → 'full';
|
|
31
32
|
// (4) two subscriber-gated pollers of the tmux-derived signatures ([[sessions]]) that never touch a file —
|
|
32
33
|
// a 100ms HOT syscall poll and a 1s WARM tmux poll, both → 'sessions'; (5) a delta-gated ~15s cold-tick
|
|
33
|
-
// PATROL that
|
|
34
|
-
//
|
|
35
|
-
// without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
|
|
34
|
+
// PATROL that asks graph-cache to validate its owned input revision — unchanged inputs reuse the anchor,
|
|
35
|
+
// while moved inputs select sessions/full there and missed leaf signals stay loud through repair accounting.
|
|
36
|
+
// Plain mode without delta subscribers keeps its zero-build behavior: sources just fan out `graph-changed`.
|
|
36
37
|
|
|
37
38
|
type Scope = 'sessions' | 'full'
|
|
38
39
|
type EvalTarget = 'all' | { id?: string; path?: string }
|
|
39
40
|
type Notify = () => void
|
|
40
41
|
type Frame = { event: string; data: string }
|
|
41
42
|
type DeltaSend = (frame: Frame) => void
|
|
43
|
+
type TreeWatchCallback = (event: 'rename' | 'change', filename: string | Buffer | null) => void
|
|
44
|
+
// the one filesystem primitive the registry is allowed to call — `fs.watch`'s overloads narrowed to the
|
|
45
|
+
// exact shape both transports use, so an injected fake is the same three arguments.
|
|
46
|
+
type WatchFactory = (path: string, options: { recursive?: boolean }, callback: TreeWatchCallback) => FSWatcher
|
|
47
|
+
type WatchTransport = 'consolidated-recursive' | 'exact-directory'
|
|
48
|
+
|
|
49
|
+
type TreeWatcherOptions = {
|
|
50
|
+
root: string
|
|
51
|
+
source: string
|
|
52
|
+
scope: Scope
|
|
53
|
+
recursive?: boolean
|
|
54
|
+
ignore?: (relativePath: string) => boolean
|
|
55
|
+
watchFactory?: WatchFactory
|
|
56
|
+
transport?: WatchTransport
|
|
57
|
+
onInput: (event: 'rename' | 'change', relativePath: string) => void
|
|
58
|
+
onFailure: (error: Error) => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// @@@ watch transport - the ONE place the platform appears. Observing a tree is a single product-level
|
|
62
|
+
// registration; how many registrations the OS actually holds for it is the transport's business.
|
|
63
|
+
// consolidated-recursive — the OS itself observes a whole subtree from one registration (Darwin
|
|
64
|
+
// FSEvents, Windows ReadDirectoryChangesW). It reports by PATH, so an atomic replacement inside the
|
|
65
|
+
// tree stays visible, and exclusions filter on delivery because nothing is consumed per directory.
|
|
66
|
+
// exact-directory — no such observer exists (Linux), and Node's `recursive` option is a USERSPACE
|
|
67
|
+
// fan-out registering one inotify watch per file AND directory (measured: 201 directories -> 801
|
|
68
|
+
// watches) which, watching files by inode, goes blind the moment a file is atomically replaced. So
|
|
69
|
+
// we enumerate directories once and install one NON-recursive watch each, all multiplexed onto the
|
|
70
|
+
// loop's single shared inotify descriptor, excluding .git/node_modules at traversal time.
|
|
71
|
+
export const consolidatedRecursiveWatch = (platform: NodeJS.Platform = process.platform): boolean =>
|
|
72
|
+
platform === 'darwin' || platform === 'win32'
|
|
73
|
+
|
|
74
|
+
// The live census across every registry: `sources` is what graph-stream asked the platform for (one per
|
|
75
|
+
// canonical root), `registrations` is what the platform is actually holding for them. Both are read by the
|
|
76
|
+
// watcher-budget tests and logged under SPEXCODE_BOARD_DEBUG, so a plateau is observable on every platform
|
|
77
|
+
// rather than only where /proc exposes inotify descriptors.
|
|
78
|
+
const liveRegistries = new Set<TreeWatcherRegistry>()
|
|
79
|
+
let liveRegistrations = 0
|
|
80
|
+
export function graphWatcherCensus(): { sources: number; registrations: number } {
|
|
81
|
+
return { sources: liveRegistries.size, registrations: liveRegistrations }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class TreeWatcherRegistry {
|
|
85
|
+
readonly root: string
|
|
86
|
+
readonly source: string
|
|
87
|
+
readonly scope: Scope
|
|
88
|
+
readonly transport: WatchTransport
|
|
89
|
+
private readonly recursive: boolean
|
|
90
|
+
private readonly ignore: (relativePath: string) => boolean
|
|
91
|
+
private readonly watchFactory: WatchFactory
|
|
92
|
+
private readonly onInput: TreeWatcherOptions['onInput']
|
|
93
|
+
private readonly onFailure: TreeWatcherOptions['onFailure']
|
|
94
|
+
private readonly handles = new Map<string, FSWatcher>()
|
|
95
|
+
private refreshImmediate: ReturnType<typeof setImmediate> | null = null
|
|
96
|
+
private failed = false
|
|
97
|
+
|
|
98
|
+
constructor(options: TreeWatcherOptions) {
|
|
99
|
+
this.root = resolve(options.root)
|
|
100
|
+
this.source = options.source
|
|
101
|
+
this.scope = options.scope
|
|
102
|
+
this.recursive = options.recursive !== false
|
|
103
|
+
this.transport = options.transport
|
|
104
|
+
?? (this.recursive && consolidatedRecursiveWatch() ? 'consolidated-recursive' : 'exact-directory')
|
|
105
|
+
this.ignore = options.ignore ?? (() => false)
|
|
106
|
+
this.watchFactory = options.watchFactory ?? (watch as unknown as WatchFactory)
|
|
107
|
+
this.onInput = options.onInput
|
|
108
|
+
this.onFailure = options.onFailure
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get size(): number { return this.handles.size }
|
|
112
|
+
paths(): string[] { return [...this.handles.keys()].sort() }
|
|
113
|
+
// one kernel-side observer covers the subtree: the root is the only path we register, and no rename can
|
|
114
|
+
// make the desired set drift, so there is nothing to re-walk.
|
|
115
|
+
private get consolidated(): boolean { return this.recursive && this.transport === 'consolidated-recursive' }
|
|
116
|
+
|
|
117
|
+
private desiredDirectories(): Set<string> {
|
|
118
|
+
const desired = new Set<string>()
|
|
119
|
+
const visit = (dir: string): void => {
|
|
120
|
+
desired.add(dir)
|
|
121
|
+
if (!this.recursive || this.consolidated) return
|
|
122
|
+
let entries: import('node:fs').Dirent[]
|
|
123
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) }
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (dir !== this.root && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
126
|
+
desired.delete(dir)
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
throw error
|
|
130
|
+
}
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
|
133
|
+
const path = join(dir, entry.name)
|
|
134
|
+
const rel = relative(this.root, path)
|
|
135
|
+
if (!this.ignore(rel)) visit(path)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
visit(this.root)
|
|
139
|
+
return desired
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private closeHandles(): void {
|
|
143
|
+
if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
|
|
144
|
+
const handles = [...this.handles.values()]
|
|
145
|
+
this.handles.clear()
|
|
146
|
+
liveRegistrations -= handles.length
|
|
147
|
+
liveRegistries.delete(this)
|
|
148
|
+
for (const handle of handles) { try { handle.close() } catch { /* already gone */ } }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private sourceError(path: string, error: unknown): Error {
|
|
152
|
+
const err = error as NodeJS.ErrnoException
|
|
153
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
154
|
+
// source + path + errno, always — an exhausted platform must name which syscall budget it refused.
|
|
155
|
+
const errno = err?.code && !reason.includes(err.code) ? ` (errno ${err.code})` : ''
|
|
156
|
+
return new Error(`spec-cli: graph watcher '${this.source}' failed at ${path}: ${reason}${errno}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fail(path: string, error: unknown): false {
|
|
160
|
+
if (this.failed) return false
|
|
161
|
+
this.failed = true
|
|
162
|
+
this.closeHandles()
|
|
163
|
+
this.onFailure(this.sourceError(path, error))
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private scheduleRefresh(): void {
|
|
168
|
+
if (this.refreshImmediate || this.failed) return
|
|
169
|
+
this.refreshImmediate = setImmediate(() => {
|
|
170
|
+
this.refreshImmediate = null
|
|
171
|
+
this.refresh()
|
|
172
|
+
})
|
|
173
|
+
this.refreshImmediate.unref?.()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
refresh(): boolean {
|
|
177
|
+
if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
|
|
178
|
+
this.failed = false
|
|
179
|
+
let desired: Set<string>
|
|
180
|
+
try { desired = this.desiredDirectories() }
|
|
181
|
+
catch (error) { return this.fail(this.root, error) }
|
|
182
|
+
|
|
183
|
+
const before = this.handles.size
|
|
184
|
+
for (const [path, handle] of [...this.handles]) {
|
|
185
|
+
if (desired.has(path)) continue
|
|
186
|
+
this.handles.delete(path)
|
|
187
|
+
liveRegistrations--
|
|
188
|
+
try { handle.close() } catch { /* already gone */ }
|
|
189
|
+
}
|
|
190
|
+
const options = this.consolidated ? { recursive: true } : {}
|
|
191
|
+
for (const path of desired) {
|
|
192
|
+
if (this.handles.has(path)) continue
|
|
193
|
+
try {
|
|
194
|
+
let handle: FSWatcher
|
|
195
|
+
const callback: TreeWatchCallback = (event, filename) => {
|
|
196
|
+
if (this.handles.get(path) !== handle) return
|
|
197
|
+
if (filename == null) { this.fail(path, new Error('pathless filesystem event')); return }
|
|
198
|
+
const inputPath = resolve(path, String(filename))
|
|
199
|
+
const rel = relative(this.root, inputPath)
|
|
200
|
+
if (rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || rel === '..') {
|
|
201
|
+
this.fail(path, new Error(`event escaped watcher root: ${String(filename)}`))
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (this.ignore(rel)) return
|
|
205
|
+
this.onInput(event, rel)
|
|
206
|
+
// only the userspace transport's desired set can drift: a new directory under a consolidated
|
|
207
|
+
// observer is already covered by the kernel.
|
|
208
|
+
if (this.recursive && !this.consolidated && event === 'rename') this.scheduleRefresh()
|
|
209
|
+
}
|
|
210
|
+
handle = this.watchFactory(path, options, callback)
|
|
211
|
+
this.handles.set(path, handle)
|
|
212
|
+
liveRegistrations++
|
|
213
|
+
handle.on('error', (error) => {
|
|
214
|
+
if (this.handles.get(path) === handle) this.fail(path, error)
|
|
215
|
+
})
|
|
216
|
+
} catch (error) { return this.fail(path, error) }
|
|
217
|
+
}
|
|
218
|
+
if (this.handles.size) liveRegistries.add(this)
|
|
219
|
+
if (DEBUG && this.handles.size !== before)
|
|
220
|
+
console.warn(`spec-cli: graph watchers — sources=${liveRegistries.size} registrations=${liveRegistrations} (${this.source}: ${this.transport} ${this.handles.size})`)
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
close(): void {
|
|
225
|
+
this.failed = false
|
|
226
|
+
this.closeHandles()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
42
230
|
const plainSubs = new Set<Notify>()
|
|
43
231
|
const deltaSubs = new Set<DeltaSend>()
|
|
44
232
|
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
@@ -72,23 +260,42 @@ let lastTag = ''
|
|
|
72
260
|
let lastFullFrame: Frame | null = null
|
|
73
261
|
let building = false
|
|
74
262
|
let dirty = false
|
|
263
|
+
let patrolPending = false
|
|
75
264
|
|
|
76
|
-
async function rebuildAndBroadcast(): Promise<void> {
|
|
265
|
+
async function rebuildAndBroadcast(patrol = false): Promise<void> {
|
|
266
|
+
if (patrol) patrolPending = true
|
|
77
267
|
if (building) { dirty = true; return }
|
|
78
268
|
building = true
|
|
79
269
|
try {
|
|
80
270
|
do {
|
|
81
271
|
dirty = false
|
|
272
|
+
const validate = patrolPending
|
|
273
|
+
patrolPending = false
|
|
82
274
|
let board: unknown
|
|
83
275
|
// share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
|
|
84
276
|
// cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
|
|
85
|
-
// already started).
|
|
277
|
+
// already started). The patrol instead asks that same cache flight to validate its input revision;
|
|
278
|
+
// equal inputs return the anchor without invoking a producer.
|
|
86
279
|
const t0 = Date.now()
|
|
87
|
-
try { board = await
|
|
280
|
+
try { board = await (validate ? patrolBoard() : getBoard()) }
|
|
281
|
+
catch {
|
|
282
|
+
// A failed refresh consumes no cause: graph-cache restores the producer scope, so its stream-side
|
|
283
|
+
// attribution must remain owed too. This also retains watcher causes that arrived while the failed
|
|
284
|
+
// flight was occupied. A later patrol may recover the work, but it is then one cause alongside those
|
|
285
|
+
// healthy leaf signals rather than a false patrol-only blind-watcher repair.
|
|
286
|
+
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
88
289
|
const buildMs = Date.now() - t0
|
|
89
290
|
const boardJson = JSON.stringify(board)
|
|
90
291
|
const { units, ok } = unitize(board as Record<string, unknown>)
|
|
91
292
|
const tag = tagOf(units)
|
|
293
|
+
// the trigger set describes who caused THIS rebuild, so it is consumed by the rebuild — not by the
|
|
294
|
+
// broadcast. Clearing it only when content moved let a no-op fire (every poller's first sample is
|
|
295
|
+
// one) leave its tag behind forever, and the next genuine patrol repair then read as leaf-signalled
|
|
296
|
+
// and went silent — the alarm suppressing itself on the very machines that need it.
|
|
297
|
+
const tags = [...triggerTags]
|
|
298
|
+
triggerTags.clear()
|
|
92
299
|
if (tag === lastTag) continue
|
|
93
300
|
// the changed unit keys — computed against the prior anchor when we have one (a first paint has no
|
|
94
301
|
// anchor, so no repair claim can be made against it).
|
|
@@ -113,12 +320,10 @@ async function rebuildAndBroadcast(): Promise<void> {
|
|
|
113
320
|
// means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
|
|
114
321
|
// it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
|
|
115
322
|
// broadcast logs its changed keys + triggers + build ms.
|
|
116
|
-
const tags = [...triggerTags]
|
|
117
323
|
if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
|
|
118
324
|
console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`)
|
|
119
325
|
if (DEBUG)
|
|
120
326
|
console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`)
|
|
121
|
-
triggerTags.clear()
|
|
122
327
|
} while (dirty)
|
|
123
328
|
} finally { building = false }
|
|
124
329
|
}
|
|
@@ -162,280 +367,469 @@ export const notifyBoardChanged = (scope: Scope = 'full'): void =>
|
|
|
162
367
|
// Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
|
|
163
368
|
setSessionEvalProjectionNotify(() => fireChanged('sessions'))
|
|
164
369
|
|
|
370
|
+
// ---- ONE repair scheduler for every filesystem source ----
|
|
371
|
+
// A source the platform refuses keeps its observer hold and is retried by THIS timer alone — never by a
|
|
372
|
+
// graph build, an HTTP read, a poller tick or a registry event. That is the whole anti-storm rule: the
|
|
373
|
+
// resource being exhausted (a registration budget, a descriptor table) is PROCESS-wide, so its backoff
|
|
374
|
+
// belongs to the process, not to each source racing its own retry and re-walking the corpus every time.
|
|
375
|
+
// `heldSources` is what an ordinary pass may not re-attempt; only the scheduled pass lifts that.
|
|
376
|
+
const heldSources = new Set<string>()
|
|
377
|
+
let repairTimer: ReturnType<typeof setTimeout> | null = null
|
|
378
|
+
let repairStep = 0
|
|
379
|
+
let repairing = false
|
|
380
|
+
|
|
381
|
+
const mayAttach = (source: string): boolean => repairing || !heldSources.has(source)
|
|
382
|
+
|
|
383
|
+
// The failure is loud ONCE per episode and names source + path + errno (the registry built that message);
|
|
384
|
+
// every later source felled by the same exhausted budget is counted, not re-printed, and the repair line
|
|
385
|
+
// reports the total. A half-attached registry is already closed by the registry itself before we get here.
|
|
386
|
+
function noteSourceFailure(source: string, error: unknown): void {
|
|
387
|
+
const known = heldSources.has(source)
|
|
388
|
+
heldSources.add(source)
|
|
389
|
+
if (!known && heldSources.size === 1) console.error(error instanceof Error ? error.message : String(error))
|
|
390
|
+
scheduleWatcherRepair()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function noteSourceHealthy(source: string): void {
|
|
394
|
+
if (!heldSources.delete(source)) return
|
|
395
|
+
if (heldSources.size === 0) repairStep = 0
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function scheduleWatcherRepair(): void {
|
|
399
|
+
if (repairTimer || !heldSources.size) return
|
|
400
|
+
const delay = Math.min(30_000, 250 * 2 ** Math.min(repairStep, 7))
|
|
401
|
+
repairStep++
|
|
402
|
+
const era = watcherEra
|
|
403
|
+
console.error(`spec-cli: graph watcher repair — ${heldSources.size} source(s) held, retrying in ${delay}ms; the cold-tick patrol covers the gap until they reattach`)
|
|
404
|
+
repairTimer = setTimeout(() => {
|
|
405
|
+
repairTimer = null
|
|
406
|
+
if (era !== watcherEra) return
|
|
407
|
+
repairing = true
|
|
408
|
+
void ensureBoardFileWatchers()
|
|
409
|
+
.catch((error) => console.error(`spec-cli: graph watcher repair failed — ${error instanceof Error ? error.message : String(error)}`))
|
|
410
|
+
.finally(() => {
|
|
411
|
+
repairing = false
|
|
412
|
+
if (heldSources.size) scheduleWatcherRepair()
|
|
413
|
+
})
|
|
414
|
+
}, delay)
|
|
415
|
+
repairTimer.unref?.()
|
|
416
|
+
}
|
|
417
|
+
|
|
165
418
|
// ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
|
|
166
|
-
let
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
419
|
+
let activeStoreRoot: string | null = null
|
|
420
|
+
let activeCommonRoot: string | null = null
|
|
421
|
+
let watcherEra = 0
|
|
422
|
+
let storeWatcher: TreeWatcherRegistry | null = null
|
|
423
|
+
|
|
424
|
+
function ensureWatcher(root: string): void {
|
|
425
|
+
if (storeWatcher?.root === root) return
|
|
426
|
+
if (storeWatcher) { storeWatcher.close(); storeWatcher = null }
|
|
427
|
+
if (isDisabled('store') || !mayAttach('store')) return
|
|
428
|
+
try { mkdirSync(root, { recursive: true }) }
|
|
429
|
+
catch (error) {
|
|
430
|
+
console.error(`spec-cli: graph watcher 'store' could not create ${root}: ${error instanceof Error ? error.message : String(error)}`)
|
|
431
|
+
}
|
|
432
|
+
const registry = new TreeWatcherRegistry({
|
|
433
|
+
root,
|
|
434
|
+
source: 'store',
|
|
435
|
+
scope: 'sessions',
|
|
436
|
+
onInput: () => fireChanged('sessions'),
|
|
437
|
+
onFailure: (error) => {
|
|
438
|
+
if (storeWatcher === registry) storeWatcher = null
|
|
439
|
+
noteSourceFailure('store', error)
|
|
440
|
+
fireChanged('sessions')
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
storeWatcher = registry
|
|
444
|
+
if (!registry.refresh()) {
|
|
445
|
+
if (storeWatcher === registry) storeWatcher = null
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
noteSourceHealthy('store')
|
|
173
449
|
}
|
|
174
450
|
|
|
175
451
|
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
|
|
176
452
|
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
177
453
|
// for packed-refs rewrites and HEAD flips. Ordinary graph units still have the patrol; eval projections are
|
|
178
454
|
// observer-held across a failure and only become current after a replacement watch authorizes a rescan.
|
|
179
|
-
|
|
455
|
+
type RegistryGroup = {
|
|
456
|
+
root: string
|
|
457
|
+
close(): void
|
|
458
|
+
}
|
|
459
|
+
let refsWatchers: RegistryGroup | null = null
|
|
180
460
|
const REFS_OBSERVER = 'graph:refs'
|
|
181
461
|
|
|
182
462
|
export function watchSessionEvalRefs(
|
|
183
463
|
common: string,
|
|
184
464
|
onInput: () => void,
|
|
185
|
-
onFailure: () => void,
|
|
186
|
-
):
|
|
187
|
-
|
|
465
|
+
onFailure: (error: Error) => void,
|
|
466
|
+
): RegistryGroup {
|
|
467
|
+
let attached: TreeWatcherRegistry[] = []
|
|
188
468
|
let failed = false
|
|
469
|
+
let ready = false
|
|
470
|
+
let attachError: Error | null = null
|
|
189
471
|
const close = () => {
|
|
190
|
-
|
|
472
|
+
const registries = attached
|
|
473
|
+
attached = []
|
|
474
|
+
for (const registry of registries) registry.close()
|
|
191
475
|
}
|
|
192
|
-
const fail = () => {
|
|
476
|
+
const fail = (error: Error) => {
|
|
193
477
|
if (failed) return
|
|
194
478
|
failed = true
|
|
195
479
|
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
|
|
480
|
+
if (ready) onFailure(error)
|
|
481
|
+
else attachError = error
|
|
212
482
|
}
|
|
483
|
+
const refs = new TreeWatcherRegistry({
|
|
484
|
+
root: join(common, 'refs'),
|
|
485
|
+
source: 'refs',
|
|
486
|
+
scope: 'full',
|
|
487
|
+
onInput: () => onInput(),
|
|
488
|
+
onFailure: fail,
|
|
489
|
+
})
|
|
490
|
+
attached.push(refs)
|
|
491
|
+
if (!refs.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs' failed at ${refs.root}`)
|
|
492
|
+
|
|
493
|
+
const commonFiles = new TreeWatcherRegistry({
|
|
494
|
+
root: common,
|
|
495
|
+
source: 'refs-common',
|
|
496
|
+
scope: 'full',
|
|
497
|
+
recursive: false,
|
|
498
|
+
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() },
|
|
499
|
+
onFailure: fail,
|
|
500
|
+
})
|
|
501
|
+
attached.push(commonFiles)
|
|
502
|
+
if (!commonFiles.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs-common' failed at ${common}`)
|
|
503
|
+
ready = true
|
|
504
|
+
return { root: resolve(common), close }
|
|
213
505
|
}
|
|
214
506
|
|
|
215
|
-
function refsWatcherFailed(): void {
|
|
507
|
+
function refsWatcherFailed(error: Error): void {
|
|
216
508
|
refsWatchers = null
|
|
509
|
+
noteSourceFailure('refs', error)
|
|
217
510
|
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
218
|
-
setImmediate(() => ensureRefsWatcher(false))
|
|
219
511
|
}
|
|
220
512
|
|
|
221
|
-
function ensureRefsWatcher(
|
|
222
|
-
if (
|
|
513
|
+
function ensureRefsWatcher(common = activeCommonRoot): void {
|
|
514
|
+
if (!common) return
|
|
515
|
+
if (refsWatchers?.root === common) return
|
|
516
|
+
if (refsWatchers) { refsWatchers.close(); refsWatchers = null }
|
|
223
517
|
if (isDisabled('refs')) {
|
|
224
518
|
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
225
519
|
return
|
|
226
520
|
}
|
|
521
|
+
if (!mayAttach('refs')) return
|
|
227
522
|
try {
|
|
228
|
-
const common = gitCommonDir()
|
|
229
523
|
refsWatchers = watchSessionEvalRefs(common, () => fireChanged('full', 'all'), refsWatcherFailed)
|
|
524
|
+
noteSourceHealthy('refs')
|
|
230
525
|
if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
|
|
231
|
-
} catch {
|
|
526
|
+
} catch (error) {
|
|
232
527
|
refsWatchers = null
|
|
528
|
+
noteSourceFailure('refs', error)
|
|
233
529
|
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
234
|
-
if (retry) setImmediate(() => ensureRefsWatcher(false))
|
|
235
530
|
}
|
|
236
531
|
}
|
|
237
532
|
|
|
238
533
|
// ---- 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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
let registryWatcher:
|
|
243
|
-
|
|
534
|
+
// Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each live
|
|
535
|
+
// worktree is therefore TWO canonical roots — its working tree and git's metadata dir for it (`index`) —
|
|
536
|
+
// and that pair is the whole per-worktree registration cost, whatever the corpus inside it holds.
|
|
537
|
+
let registryWatcher: TreeWatcherRegistry | null = null
|
|
538
|
+
let registryReady = false
|
|
539
|
+
type WorktreeWatch = {
|
|
540
|
+
path: string
|
|
541
|
+
root: TreeWatcherRegistry
|
|
542
|
+
index: TreeWatcherRegistry
|
|
543
|
+
close(): void
|
|
544
|
+
}
|
|
244
545
|
const worktreeWatchers = new Map<string, WorktreeWatch>()
|
|
245
|
-
const worktreeRetryAttempted = new Set<string>()
|
|
246
|
-
const worktreeRetryCount = new Map<string, number>()
|
|
247
546
|
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
|
-
}
|
|
547
|
+
const worktreeSource = (name: string): string => `worktree:${name}`
|
|
272
548
|
|
|
273
549
|
const ignoredWorktreePath = (file: string): boolean =>
|
|
274
|
-
file
|
|
550
|
+
file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules')
|
|
275
551
|
|
|
276
552
|
export function watchSessionEvalWorktree(
|
|
277
553
|
wtPath: string,
|
|
278
554
|
gitDir: string,
|
|
279
555
|
onInput: () => void,
|
|
280
|
-
onFailure: () => void,
|
|
281
|
-
):
|
|
556
|
+
onFailure: (error: Error) => void,
|
|
557
|
+
): WorktreeWatch {
|
|
282
558
|
let failed = false
|
|
283
|
-
let
|
|
284
|
-
let
|
|
285
|
-
|
|
559
|
+
let ready = false
|
|
560
|
+
let attachError: Error | null = null
|
|
561
|
+
let root: TreeWatcherRegistry | null = null
|
|
562
|
+
let index: TreeWatcherRegistry | null = null
|
|
563
|
+
const close = () => {
|
|
564
|
+
root?.close()
|
|
565
|
+
index?.close()
|
|
566
|
+
}
|
|
567
|
+
const fail = (error: Error) => {
|
|
286
568
|
if (failed) return
|
|
287
569
|
failed = true
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
570
|
+
close()
|
|
571
|
+
if (ready) onFailure(error)
|
|
572
|
+
else attachError = error
|
|
308
573
|
}
|
|
574
|
+
root = new TreeWatcherRegistry({
|
|
575
|
+
root: wtPath,
|
|
576
|
+
source: `worktree:${resolve(wtPath)}`,
|
|
577
|
+
scope: 'full',
|
|
578
|
+
ignore: ignoredWorktreePath,
|
|
579
|
+
onInput: () => onInput(),
|
|
580
|
+
onFailure: fail,
|
|
581
|
+
})
|
|
582
|
+
if (!root.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree' failed at ${wtPath}`)
|
|
583
|
+
index = new TreeWatcherRegistry({
|
|
584
|
+
root: gitDir,
|
|
585
|
+
source: `worktree-index:${resolve(wtPath)}`,
|
|
586
|
+
scope: 'full',
|
|
587
|
+
recursive: false,
|
|
588
|
+
onInput: (_event, file) => { if (file === 'index') onInput() },
|
|
589
|
+
onFailure: fail,
|
|
590
|
+
})
|
|
591
|
+
if (!index.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-index' failed at ${gitDir}`)
|
|
592
|
+
ready = true
|
|
593
|
+
return { path: resolve(wtPath), root, index, close }
|
|
309
594
|
}
|
|
310
595
|
|
|
311
596
|
function dropWorktreeWatcher(name: string): WorktreeWatch | null {
|
|
312
597
|
const row = worktreeWatchers.get(name)
|
|
313
598
|
if (!row) return null
|
|
314
599
|
worktreeWatchers.delete(name)
|
|
315
|
-
|
|
316
|
-
try { row.index.close() } catch { /* already gone */ }
|
|
600
|
+
row.close()
|
|
317
601
|
return row
|
|
318
602
|
}
|
|
319
603
|
|
|
320
|
-
function watcherFailed(name: string, path: string): void {
|
|
321
|
-
// Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and
|
|
322
|
-
//
|
|
604
|
+
function watcherFailed(name: string, path: string, error: Error): void {
|
|
605
|
+
// Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and hand the
|
|
606
|
+
// reattach to the one repair scheduler; the authorized summary build is the one authoritative rescan,
|
|
607
|
+
// never a periodic sweep and never a re-walk driven by whatever read noticed the failure.
|
|
323
608
|
dropWorktreeWatcher(name)
|
|
609
|
+
noteSourceFailure(worktreeSource(name), error)
|
|
324
610
|
if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path })) fireChanged('full')
|
|
325
|
-
scheduleWorktreeRetry(name)
|
|
326
611
|
}
|
|
327
612
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
613
|
+
const forcedWorktreeSessions = new Set<string>()
|
|
614
|
+
let worktreeReconcileFlight: Promise<void> | null = null
|
|
615
|
+
|
|
616
|
+
export function sessionWorktreeWatchPaths(
|
|
617
|
+
sessions: { id: string; path: string; liveness?: string }[],
|
|
618
|
+
forcedSessions: Set<string> = new Set(),
|
|
619
|
+
): Set<string> {
|
|
620
|
+
return new Set(sessions
|
|
621
|
+
.filter((session) => session.liveness !== 'offline' || forcedSessions.has(session.id))
|
|
622
|
+
.map((session) => resolve(session.path)))
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function reconcileWorktreePass(forcedSessions: Set<string>, era: number, common: string): Promise<void> {
|
|
626
|
+
const dir = join(common, 'worktrees')
|
|
627
|
+
let sessions: Awaited<ReturnType<typeof listSessions>>
|
|
628
|
+
try { sessions = await listSessions() } catch { return }
|
|
629
|
+
if (era !== watcherEra) return
|
|
630
|
+
const wantedPaths = sessionWorktreeWatchPaths(sessions, forcedSessions)
|
|
331
631
|
let ents: import('node:fs').Dirent[] = []
|
|
332
632
|
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
|
|
333
|
-
const
|
|
633
|
+
const wantedNames = new Set<string>()
|
|
334
634
|
let released = false
|
|
335
635
|
for (const e of ents) {
|
|
336
636
|
if (!e.isDirectory()) continue
|
|
337
|
-
|
|
338
|
-
|
|
637
|
+
let wtPath: string
|
|
638
|
+
try { wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { continue }
|
|
639
|
+
const normalizedPath = resolve(wtPath)
|
|
640
|
+
if (!wantedPaths.has(normalizedPath)) {
|
|
641
|
+
if (dropWorktreeWatcher(e.name)) released = releaseSessionEvalProjectionObserver(worktreeObserver(e.name)) || released
|
|
642
|
+
noteSourceHealthy(worktreeSource(e.name))
|
|
643
|
+
continue
|
|
644
|
+
}
|
|
645
|
+
wantedNames.add(e.name)
|
|
646
|
+
const existing = worktreeWatchers.get(e.name)
|
|
647
|
+
if (existing?.path === normalizedPath) continue
|
|
648
|
+
const rootChanged = existing != null
|
|
649
|
+
if (existing) dropWorktreeWatcher(e.name)
|
|
650
|
+
// a held worktree is the repair scheduler's to reattach — an ordinary reconcile pass must not re-walk
|
|
651
|
+
// it, which is what turned one refused registration into a per-read attach storm.
|
|
652
|
+
if (!mayAttach(worktreeSource(e.name))) continue
|
|
339
653
|
try {
|
|
340
654
|
// the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
|
|
341
|
-
const
|
|
342
|
-
const { root, index } = watchSessionEvalWorktree(
|
|
655
|
+
const row = watchSessionEvalWorktree(
|
|
343
656
|
wtPath,
|
|
344
657
|
join(dir, e.name),
|
|
345
658
|
() => fireChanged('full', { path: wtPath }),
|
|
346
|
-
() => watcherFailed(e.name, wtPath),
|
|
659
|
+
(error) => watcherFailed(e.name, wtPath, error),
|
|
347
660
|
)
|
|
348
|
-
|
|
661
|
+
if (era !== watcherEra) { row.close(); return }
|
|
349
662
|
worktreeWatchers.set(e.name, row)
|
|
350
|
-
|
|
351
|
-
|
|
663
|
+
noteSourceHealthy(worktreeSource(e.name))
|
|
664
|
+
if (rootChanged) fireChanged('full', { path: wtPath })
|
|
352
665
|
// The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
|
|
353
666
|
// so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
|
|
354
667
|
if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name))) fireChanged('full')
|
|
355
|
-
} catch {
|
|
356
|
-
// An attach failure is observable: mark unknown/full now. The
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), path ? { path } : 'all')) fireChanged('full')
|
|
361
|
-
scheduleWorktreeRetry(e.name)
|
|
668
|
+
} catch (error) {
|
|
669
|
+
// An attach failure is observable: mark unknown/full now. The repair scheduler owns the reattach; no
|
|
670
|
+
// patrol is allowed to call the eval projection current meanwhile.
|
|
671
|
+
noteSourceFailure(worktreeSource(e.name), error)
|
|
672
|
+
if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), { path: wtPath })) fireChanged('full')
|
|
362
673
|
}
|
|
363
674
|
}
|
|
364
|
-
for (const name of worktreeWatchers.keys()) if (!
|
|
675
|
+
for (const name of worktreeWatchers.keys()) if (!wantedNames.has(name)) {
|
|
365
676
|
dropWorktreeWatcher(name)
|
|
366
677
|
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
367
678
|
}
|
|
368
|
-
for (const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
679
|
+
for (const source of [...heldSources]) {
|
|
680
|
+
const name = source.startsWith('worktree:') ? source.slice('worktree:'.length) : null
|
|
681
|
+
if (name && !wantedNames.has(name)) {
|
|
682
|
+
noteSourceHealthy(source)
|
|
683
|
+
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
684
|
+
}
|
|
372
685
|
}
|
|
373
686
|
if (released) fireChanged('full')
|
|
374
687
|
}
|
|
688
|
+
|
|
689
|
+
function reconcileWorktrees(forceSessionId?: string): Promise<void> {
|
|
690
|
+
// Blinding a leaf must blind it from EVERY entry point. Reconciliation is reached from the liveness
|
|
691
|
+
// poller and from registry events as well as from the ensure pass, so gating only the ensure pass left
|
|
692
|
+
// the per-worktree observers attaching anyway — the injection looked applied while the leaf still saw
|
|
693
|
+
// everything, which makes the patrol's accountability untestable rather than merely untested.
|
|
694
|
+
if (isDisabled('worktrees')) return Promise.resolve()
|
|
695
|
+
if (forceSessionId) forcedWorktreeSessions.add(forceSessionId)
|
|
696
|
+
if (worktreeReconcileFlight) return worktreeReconcileFlight
|
|
697
|
+
const era = watcherEra
|
|
698
|
+
const common = activeCommonRoot
|
|
699
|
+
if (!common) return Promise.resolve()
|
|
700
|
+
const flight = (async () => {
|
|
701
|
+
do {
|
|
702
|
+
if (era !== watcherEra) return
|
|
703
|
+
const forced = new Set(forcedWorktreeSessions)
|
|
704
|
+
forcedWorktreeSessions.clear()
|
|
705
|
+
await reconcileWorktreePass(forced, era, common)
|
|
706
|
+
} while (forcedWorktreeSessions.size)
|
|
707
|
+
})().finally(() => { if (worktreeReconcileFlight === flight) worktreeReconcileFlight = null })
|
|
708
|
+
worktreeReconcileFlight = flight
|
|
709
|
+
void flight.catch((error) => {
|
|
710
|
+
console.warn(`spec-cli: worktree watcher reconciliation failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
711
|
+
})
|
|
712
|
+
return flight
|
|
713
|
+
}
|
|
375
714
|
const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
|
|
376
715
|
|
|
377
716
|
export function watchSessionEvalRegistry(
|
|
378
717
|
dir: string,
|
|
379
718
|
onInput: () => void,
|
|
380
|
-
onFailure: () => void,
|
|
381
|
-
):
|
|
382
|
-
let
|
|
383
|
-
let
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
try { watcher?.close() } catch { /* partial attach */ }
|
|
399
|
-
throw error
|
|
400
|
-
}
|
|
719
|
+
onFailure: (error: Error) => void,
|
|
720
|
+
): TreeWatcherRegistry {
|
|
721
|
+
let ready = false
|
|
722
|
+
let attachError: Error | null = null
|
|
723
|
+
const registry = new TreeWatcherRegistry({
|
|
724
|
+
root: dir,
|
|
725
|
+
source: 'worktree-registry',
|
|
726
|
+
scope: 'full',
|
|
727
|
+
recursive: false,
|
|
728
|
+
onInput: () => onInput(),
|
|
729
|
+
onFailure: (error) => {
|
|
730
|
+
if (ready) onFailure(error)
|
|
731
|
+
else attachError = error
|
|
732
|
+
},
|
|
733
|
+
})
|
|
734
|
+
if (!registry.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-registry' failed at ${dir}`)
|
|
735
|
+
ready = true
|
|
736
|
+
return registry
|
|
401
737
|
}
|
|
402
738
|
|
|
403
|
-
function registryWatcherFailed(): void {
|
|
739
|
+
function registryWatcherFailed(error: Error): void {
|
|
404
740
|
registryWatcher = null
|
|
741
|
+
registryReady = false
|
|
742
|
+
noteSourceFailure('worktree-registry', error)
|
|
405
743
|
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
406
|
-
setImmediate(() => ensureWorktreeRegistry(false))
|
|
407
744
|
}
|
|
408
745
|
|
|
409
|
-
function ensureWorktreeRegistry(
|
|
410
|
-
|
|
746
|
+
async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
|
|
747
|
+
const common = activeCommonRoot
|
|
748
|
+
if (!common) return
|
|
749
|
+
const dir = resolve(join(common, 'worktrees'))
|
|
750
|
+
// The registry watcher already reconciles add/remove events. Re-scanning every ordinary graph/evals read
|
|
751
|
+
// turns a large worktree registry into an artificial request latency floor; only a scoped read may demand
|
|
752
|
+
// one target after startup, while the unscoped hot path reuses the attached live watchers.
|
|
753
|
+
// an attached registry short-circuits the scan for ordinary reads, but a REPAIR pass exists precisely to
|
|
754
|
+
// reattach held worktrees, so it must reach the reconcile even when the registry itself is healthy.
|
|
755
|
+
if (registryWatcher?.root === dir) {
|
|
756
|
+
if (forceSessionId || repairing) await reconcileWorktrees(forceSessionId)
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
if (registryWatcher) { registryWatcher.close(); registryWatcher = null; registryReady = false }
|
|
760
|
+
// A platform may reject registry watches. Once the initial reconciliation has run, ordinary
|
|
761
|
+
// reads must not repeat its full worktree scan while the repair pass is pending; scoped reads can
|
|
762
|
+
// still demand their target explicitly.
|
|
763
|
+
if (registryReady && !forceSessionId && !repairing) return
|
|
764
|
+
if (registryReady && (forceSessionId || repairing)) { await reconcileWorktrees(forceSessionId); return }
|
|
411
765
|
if (isDisabled('worktrees')) {
|
|
766
|
+
registryReady = true
|
|
412
767
|
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
413
768
|
return
|
|
414
769
|
}
|
|
770
|
+
if (!mayAttach('worktree-registry')) return
|
|
771
|
+
registryReady = true
|
|
415
772
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
773
|
+
try { mkdirSync(dir, { recursive: true }) }
|
|
774
|
+
catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
418
775
|
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
419
776
|
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
420
777
|
registryWatcher = watchSessionEvalRegistry(dir, () => {
|
|
421
|
-
reconcileWorktrees()
|
|
778
|
+
void reconcileWorktrees()
|
|
422
779
|
fireChanged('full', 'all')
|
|
423
780
|
}, registryWatcherFailed)
|
|
424
|
-
|
|
781
|
+
noteSourceHealthy('worktree-registry')
|
|
782
|
+
} catch (error) {
|
|
425
783
|
registryWatcher = null
|
|
784
|
+
registryReady = false
|
|
785
|
+
noteSourceFailure('worktree-registry', error)
|
|
426
786
|
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
427
|
-
if (retry) setImmediate(() => ensureWorktreeRegistry(false))
|
|
428
787
|
}
|
|
429
|
-
reconcileWorktrees() // attach for the worktrees that already exist
|
|
788
|
+
await reconcileWorktrees(forceSessionId) // attach for the live/demanded worktrees that already exist
|
|
430
789
|
if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)) fireChanged('full')
|
|
431
790
|
}
|
|
432
791
|
|
|
433
792
|
// 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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
793
|
+
// request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur. A source
|
|
794
|
+
// the platform refused is NOT retried here — it is held for the repair scheduler, so an HTTP read never
|
|
795
|
+
// becomes a reattach loop.
|
|
796
|
+
export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<void> {
|
|
797
|
+
const storeRoot = resolve(sessionsRoot())
|
|
798
|
+
const commonRoot = resolve(gitCommonDir())
|
|
799
|
+
if ((activeStoreRoot && activeStoreRoot !== storeRoot) || (activeCommonRoot && activeCommonRoot !== commonRoot))
|
|
800
|
+
closeBoardFileWatchers()
|
|
801
|
+
activeStoreRoot = storeRoot
|
|
802
|
+
activeCommonRoot = commonRoot
|
|
803
|
+
ensureWatcher(storeRoot)
|
|
804
|
+
ensureRefsWatcher(commonRoot)
|
|
805
|
+
await ensureWorktreeRegistry(forceSessionId)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function closeBoardFileWatchers(): void {
|
|
809
|
+
watcherEra++
|
|
810
|
+
if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
|
|
811
|
+
heldSources.clear()
|
|
812
|
+
repairStep = 0
|
|
813
|
+
repairing = false
|
|
814
|
+
forcedWorktreeSessions.clear()
|
|
815
|
+
worktreeReconcileFlight = null
|
|
816
|
+
|
|
817
|
+
storeWatcher?.close()
|
|
818
|
+
storeWatcher = null
|
|
819
|
+
refsWatchers?.close()
|
|
820
|
+
refsWatchers = null
|
|
821
|
+
registryWatcher?.close()
|
|
822
|
+
registryWatcher = null
|
|
823
|
+
registryReady = false
|
|
824
|
+
for (const [name, row] of worktreeWatchers) {
|
|
825
|
+
row.close()
|
|
826
|
+
releaseSessionEvalProjectionObserver(worktreeObserver(name))
|
|
827
|
+
}
|
|
828
|
+
worktreeWatchers.clear()
|
|
829
|
+
releaseSessionEvalProjectionObserver(REFS_OBSERVER)
|
|
830
|
+
releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)
|
|
831
|
+
activeStoreRoot = null
|
|
832
|
+
activeCommonRoot = null
|
|
439
833
|
}
|
|
440
834
|
|
|
441
835
|
// ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
|
|
@@ -448,7 +842,9 @@ let lastHot = ''
|
|
|
448
842
|
let lastWarm = ''
|
|
449
843
|
function ensurePollers(): void {
|
|
450
844
|
if (!hotPoller) hotPoller = setInterval(() => {
|
|
451
|
-
void hotSignature().then((sig) => {
|
|
845
|
+
void hotSignature().then((sig) => {
|
|
846
|
+
if (sig !== lastHot) { lastHot = sig; void reconcileWorktrees(); fireChanged('sessions') }
|
|
847
|
+
}).catch(() => {})
|
|
452
848
|
}, 100)
|
|
453
849
|
if (!warmPoller) warmPoller = setInterval(() => {
|
|
454
850
|
void warmSignature().then((sig) => { if (sig !== lastWarm) { lastWarm = sig; fireChanged('sessions') } }).catch(() => {})
|
|
@@ -456,19 +852,17 @@ function ensurePollers(): void {
|
|
|
456
852
|
}
|
|
457
853
|
|
|
458
854
|
// ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
|
|
459
|
-
// poll, AND the self-heal authority.
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// clients keep their own client-side fallback, so without delta subscribers this must not burn builds.
|
|
855
|
+
// poll, AND the self-heal authority. A relaxed tick asks graph-cache's ONE flight to compare its board-input
|
|
856
|
+
// revision: unchanged state returns the anchor without assembly, while an unobserved ref/worktree/store move
|
|
857
|
+
// escalates there to a full rebuild. The 'patrol' tag keeps that repair accountable. Delta-gated: plain-only
|
|
858
|
+
// clients keep their own client-side fallback, so without delta subscribers this does no validation work.
|
|
464
859
|
let coldTick: ReturnType<typeof setInterval> | null = null
|
|
465
860
|
function ensureColdTick(): void {
|
|
466
861
|
if (coldTick) return
|
|
467
862
|
coldTick = setInterval(() => {
|
|
468
863
|
if (!deltaSubs.size) return
|
|
469
|
-
invalidateBoard('full')
|
|
470
864
|
triggerTags.add('patrol')
|
|
471
|
-
void rebuildAndBroadcast()
|
|
865
|
+
void rebuildAndBroadcast(true)
|
|
472
866
|
}, 15000)
|
|
473
867
|
}
|
|
474
868
|
|
|
@@ -477,7 +871,8 @@ function stopSourcesIfIdle(): void {
|
|
|
477
871
|
// never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
|
|
478
872
|
// first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
|
|
479
873
|
// 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 }
|
|
874
|
+
if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null; patrolPending = false }
|
|
875
|
+
if (deltaSubs.size === 0) setSessionEvalProjectionWarmup(false)
|
|
481
876
|
if (plainSubs.size + deltaSubs.size > 0) return
|
|
482
877
|
if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
|
|
483
878
|
if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
|
|
@@ -488,14 +883,18 @@ function stopSourcesIfIdle(): void {
|
|
|
488
883
|
// idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
|
|
489
884
|
// auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
|
|
490
885
|
// chain re-anchors with no client-side repair logic.
|
|
491
|
-
export function boardStream(c: Context) {
|
|
886
|
+
export async function boardStream(c: Context) {
|
|
492
887
|
const delta = c.req.query('mode') === 'delta'
|
|
493
|
-
ensureBoardFileWatchers()
|
|
888
|
+
await ensureBoardFileWatchers()
|
|
494
889
|
return streamSSE(c, async (stream) => {
|
|
495
890
|
let aborted = false
|
|
496
891
|
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|
|
497
892
|
const notify: Notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => {}) }
|
|
498
|
-
if (delta) {
|
|
893
|
+
if (delta) {
|
|
894
|
+
deltaSubs.add(send)
|
|
895
|
+
setSessionEvalProjectionWarmup(true)
|
|
896
|
+
ensureColdTick()
|
|
897
|
+
} else { plainSubs.add(notify) }
|
|
499
898
|
ensurePollers()
|
|
500
899
|
const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
|
|
501
900
|
stream.onAbort(() => { aborted = true; unsub() })
|