spexcode 0.1.6 → 0.2.1
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 +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/board.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { loadSpecs, deriveStatus } from './specs.js'
|
|
|
3
3
|
import { resolveLayout, readConfig } from './layout.js'
|
|
4
4
|
import { listSessions } from './sessions.js'
|
|
5
5
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
6
|
-
import {
|
|
6
|
+
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
7
|
+
import { mergedIssues } from './issues.js'
|
|
7
8
|
import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
|
|
9
|
+
import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
|
|
8
10
|
|
|
9
11
|
// a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
|
|
10
12
|
function resolveParent(path: string, byDir: Record<string, string>): string | null {
|
|
@@ -17,6 +19,22 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
|
|
|
17
19
|
return null
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
// the board's eval summary ([[board-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
|
|
23
|
+
// reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
|
|
24
|
+
// annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
|
|
25
|
+
// degradation no error would surface; the field-preservation unit test pins this contract.
|
|
26
|
+
export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
|
|
27
|
+
const seen = new Set<string>()
|
|
28
|
+
return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// the board's scenario fold ([[board-lean]]): the declared set rides SLIM — {name, tags} is everything an
|
|
32
|
+
// overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
|
|
33
|
+
// expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
|
|
34
|
+
export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
|
|
35
|
+
return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export async function buildBoard() {
|
|
21
39
|
// all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
|
|
22
40
|
// REUSES the HEAD-keyed spec-history cache (the git-derived node data — see specs.ts/git.ts), resolveLayout
|
|
@@ -77,35 +95,46 @@ export async function buildBoard() {
|
|
|
77
95
|
const nodes = [
|
|
78
96
|
...specs.map((n: any) => {
|
|
79
97
|
const overlays = overlaysByNode[n.id] || []
|
|
80
|
-
|
|
98
|
+
// `body` and its derivation `parts` are DROPPED from the board payload ([[board-lean]]): together ~56% of
|
|
99
|
+
// the bytes, and detail the graph overview never renders. The detail view fetches them per node from
|
|
100
|
+
// `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
|
|
101
|
+
// once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
|
|
102
|
+
return { ...n, body: undefined, parts: undefined, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
|
|
81
103
|
}),
|
|
82
104
|
...Object.values(ghostById),
|
|
83
105
|
]
|
|
84
|
-
// fold
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.number - a.number)
|
|
93
|
-
}
|
|
106
|
+
// fold each node's issues onto it through the unified Issue port ([[issues]]): the resident forge slice
|
|
107
|
+
// AND the local store's threads, one merged store-tagged list (full set → issues, open subset →
|
|
108
|
+
// openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
|
|
109
|
+
// empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
|
|
110
|
+
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
111
|
+
const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
|
|
112
|
+
for (const issue of mergedIssues({ host: 'github', state: residentForgeState() }, nodes.map((n) => n.id)))
|
|
113
|
+
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
94
114
|
for (const n of nodes) {
|
|
95
115
|
const issues = issuesByNode[n.id]
|
|
96
116
|
if (!issues || !issues.length) continue
|
|
97
117
|
n.issues = issues
|
|
98
|
-
|
|
118
|
+
.sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.created.localeCompare(a.created))
|
|
119
|
+
.map((i) => ({ id: i.id, store: i.store, status: i.status, concern: i.concern, url: i.url }))
|
|
120
|
+
const open = n.issues.filter(isOpen)
|
|
99
121
|
if (open.length) n.openIssues = open
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
// fold each yatsu node's eval
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
124
|
+
// fold each yatsu node's eval state onto it — as the LEAN summary ([[board-lean]]): `evals` carries only
|
|
125
|
+
// the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
|
|
126
|
+
// badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
|
|
127
|
+
// and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
|
|
128
|
+
// SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
|
|
129
|
+
// (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
|
|
130
|
+
// (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
|
|
131
|
+
// evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes. The
|
|
132
|
+
// yatsu walk rides fs/promises ([[board-cache]]) so it yields the event loop instead of stalling /health.
|
|
133
|
+
const ynodes = await yatsuNodesAsync(root)
|
|
134
|
+
const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
|
|
106
135
|
await Promise.all(nodes.map(async (n) => {
|
|
107
136
|
const tl = await evalTimeline(n.id, ectx)
|
|
108
|
-
if (tl.hasYatsu) { n.evals = tl.readings; n.scenarios = tl.scenarios }
|
|
137
|
+
if (tl.hasYatsu) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
|
|
109
138
|
}))
|
|
110
139
|
|
|
111
140
|
const opsByPath: Record<string, any[]> = {}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { buildBoard } from './board.js'
|
|
2
|
+
|
|
3
|
+
// @@@ board-cache — single-flight + cache for the hot /api/board build ([[board-lean]]). Assembling the
|
|
4
|
+
// board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
|
|
5
|
+
// route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
|
|
6
|
+
// dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
|
|
7
|
+
// starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
|
|
8
|
+
// concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
|
|
9
|
+
// result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
|
|
10
|
+
// signals [[board-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
|
|
11
|
+
// invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
|
|
12
|
+
// the route share the very same in-flight build.
|
|
13
|
+
|
|
14
|
+
export type Board = Awaited<ReturnType<typeof buildBoard>>
|
|
15
|
+
|
|
16
|
+
// a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
|
|
17
|
+
// above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
|
|
18
|
+
// genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
|
|
19
|
+
const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
|
|
20
|
+
|
|
21
|
+
let cached: Board | null = null // last completed build; served while `valid`
|
|
22
|
+
let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
|
|
23
|
+
let valid = false
|
|
24
|
+
let inflight: Promise<Board> | null = null
|
|
25
|
+
let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
|
|
26
|
+
|
|
27
|
+
// mark the cache stale. Called by every board-stream freshness source (see boardStream.fireChanged), so a
|
|
28
|
+
// real change forces the next getBoard() to rebuild while a quiet poll storm keeps hitting the cache.
|
|
29
|
+
export function invalidateBoard(): void {
|
|
30
|
+
gen++
|
|
31
|
+
valid = false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
|
|
35
|
+
// build shares the in-flight promise; a caller after a completed build gets the cached value until the
|
|
36
|
+
// next invalidation. A change that lands WHILE a build runs (gen moved) leaves the cache invalid so the
|
|
37
|
+
// NEXT read rebuilds — the just-finished build still returns to its waiters (freshest available when they
|
|
38
|
+
// asked), never cached as current. Mirrors [[board-stream]]'s building/dirty loop.
|
|
39
|
+
export function getBoard(): Promise<Board> {
|
|
40
|
+
if (inflight) return inflight
|
|
41
|
+
if (valid && cached) return Promise.resolve(cached)
|
|
42
|
+
const startGen = gen
|
|
43
|
+
const p = (async () => {
|
|
44
|
+
const t0 = Date.now()
|
|
45
|
+
try {
|
|
46
|
+
const board = await buildBoard()
|
|
47
|
+
cached = board
|
|
48
|
+
cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
|
|
49
|
+
valid = gen === startGen
|
|
50
|
+
return board
|
|
51
|
+
} finally {
|
|
52
|
+
const ms = Date.now() - t0
|
|
53
|
+
if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
|
|
54
|
+
inflight = null
|
|
55
|
+
}
|
|
56
|
+
})()
|
|
57
|
+
inflight = p
|
|
58
|
+
return p
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// the SERIALIZED board for the /api/board route — JSON.stringify runs ONCE per build, not once per poll,
|
|
62
|
+
// so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
|
|
63
|
+
// path still takes the object (getBoard) because it decomposes it into delta units ([[board-delta]]).
|
|
64
|
+
export async function getBoardJson(): Promise<string> {
|
|
65
|
+
const board = await getBoard()
|
|
66
|
+
if (board === cached && cachedJson !== null) return cachedJson
|
|
67
|
+
const json = JSON.stringify(board)
|
|
68
|
+
if (board === cached) cachedJson = json // memoize only the CURRENT build's serialization
|
|
69
|
+
return json
|
|
70
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
|
|
4
|
+
// UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[board-stream]])
|
|
5
|
+
// chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
|
|
6
|
+
// instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
|
|
7
|
+
// dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
|
|
8
|
+
// equivalence argument (see the spec node's equivalence.md) is checkable by the property tests alone.
|
|
9
|
+
//
|
|
10
|
+
// Unit keys: `node:<id>` (one spec node), `sess:<id>` (one session row), `nodes#order` / `sess#order`
|
|
11
|
+
// (id sequences, preserving array order), `meta` (every other top-level field as one small object).
|
|
12
|
+
// Precondition P: node ids and session ids are collision-free. unitize REPORTS P (`ok`) rather than
|
|
13
|
+
// assuming it — on a violation the transport falls back to full-snapshot sends, so a delta is only ever
|
|
14
|
+
// chained between snapshots where the decomposition is a real bijection.
|
|
15
|
+
|
|
16
|
+
export type Units = Map<string, { j: string; v: unknown }>
|
|
17
|
+
export type Delta = { from: string; to: string; set: Record<string, unknown>; del: string[] }
|
|
18
|
+
|
|
19
|
+
type Boardish = { nodes?: unknown; sessions?: unknown; [k: string]: unknown }
|
|
20
|
+
|
|
21
|
+
// decompose a board into units. `ok` = the bijection precondition held (arrays are arrays, ids unique &
|
|
22
|
+
// non-empty); when false the map is still returned (usable for tagging) but must not seed a delta chain.
|
|
23
|
+
export function unitize(board: Boardish): { units: Units; ok: boolean } {
|
|
24
|
+
const units: Units = new Map()
|
|
25
|
+
let ok = true
|
|
26
|
+
const keyed = (arr: unknown, prefix: string, orderKey: string): void => {
|
|
27
|
+
const list = Array.isArray(arr) ? arr : (ok = false, [])
|
|
28
|
+
const order: string[] = []
|
|
29
|
+
for (const item of list) {
|
|
30
|
+
const id = (item as { id?: unknown })?.id
|
|
31
|
+
if (typeof id !== 'string' || !id || units.has(`${prefix}${id}`)) { ok = false; continue }
|
|
32
|
+
units.set(`${prefix}${id}`, { j: JSON.stringify(item), v: item })
|
|
33
|
+
order.push(id)
|
|
34
|
+
}
|
|
35
|
+
units.set(orderKey, { j: JSON.stringify(order), v: order })
|
|
36
|
+
}
|
|
37
|
+
const { nodes, sessions, ...meta } = board
|
|
38
|
+
keyed(nodes, 'node:', 'nodes#order')
|
|
39
|
+
keyed(sessions, 'sess:', 'sess#order')
|
|
40
|
+
units.set('meta', { j: JSON.stringify(meta), v: meta })
|
|
41
|
+
return { units, ok }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the snapshot tag: a digest over every unit's key + content hash, order-independent (keys sorted). Two
|
|
45
|
+
// builds serializing equal content get equal tags; JSON.stringify equality is conservative (equal strings ⇒
|
|
46
|
+
// equal values; a key-order difference at worst re-sends an unchanged unit, never misses a changed one).
|
|
47
|
+
export function tagOf(units: Units): string {
|
|
48
|
+
const h = createHash('sha1')
|
|
49
|
+
for (const key of [...units.keys()].sort()) {
|
|
50
|
+
const u = units.get(key)!
|
|
51
|
+
h.update(key).update('\0').update(u.j).update('\0')
|
|
52
|
+
}
|
|
53
|
+
return h.digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// diff two unit maps into the minimal patch: units whose serialized content moved land in `set` (with the
|
|
57
|
+
// NEW value), units that vanished land in `del`. apply(prev, diff(prev, next)) = next — the round-trip
|
|
58
|
+
// lemma the property tests pin down.
|
|
59
|
+
export function diffUnits(prev: Units, next: Units): { set: Record<string, unknown>; del: string[] } {
|
|
60
|
+
const set: Record<string, unknown> = {}
|
|
61
|
+
const del: string[] = []
|
|
62
|
+
for (const [key, u] of next) {
|
|
63
|
+
const p = prev.get(key)
|
|
64
|
+
if (!p || p.j !== u.j) set[key] = u.v
|
|
65
|
+
}
|
|
66
|
+
for (const key of prev.keys()) if (!next.has(key)) del.push(key)
|
|
67
|
+
return { set, del }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// apply a patch to a unit-value map — the exact algorithm the dashboard mirrors in data.js, kept here so
|
|
71
|
+
// the round-trip property is provable against the real shape, not a paraphrase of it.
|
|
72
|
+
export function applyDelta(values: Map<string, unknown>, d: Pick<Delta, 'set' | 'del'>): Map<string, unknown> {
|
|
73
|
+
const out = new Map(values)
|
|
74
|
+
for (const key of d.del) out.delete(key)
|
|
75
|
+
for (const [key, v] of Object.entries(d.set)) out.set(key, v)
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// reconstruct the board from unit values — R(U(B)) = B on the P-satisfying subspace (the client's render
|
|
80
|
+
// input after every applied patch). Order rides the #order units, so array order survives the round trip.
|
|
81
|
+
export function boardFromUnits(values: Map<string, unknown>): Boardish {
|
|
82
|
+
const pick = (prefix: string, orderKey: string): unknown[] => {
|
|
83
|
+
const order = (values.get(orderKey) as string[] | undefined) || []
|
|
84
|
+
return order.map((id) => values.get(`${prefix}${id}`))
|
|
85
|
+
}
|
|
86
|
+
const meta = (values.get('meta') as Record<string, unknown> | undefined) || {}
|
|
87
|
+
return { ...meta, nodes: pick('node:', 'nodes#order'), sessions: pick('sess:', 'sess#order') }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const unitValues = (units: Units): Map<string, unknown> => new Map([...units].map(([k, u]) => [k, u.v]))
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { watch, mkdirSync, type FSWatcher } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { sessionsRoot, gitCommonDir } from './layout.js'
|
|
6
|
+
import { sessionSignature } from './sessions.js'
|
|
7
|
+
import { getBoard, invalidateBoard } from './boardCache.js'
|
|
8
|
+
import { unitize, tagOf, diffUnits, type Units } from './boardDelta.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 `board-changed` and refetches /api/board (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 ([[board-delta]]): a `board-full {to, board}` on connect, then `board-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
|
+
// Event sources, ALL funneled into one debounced pipeline: (1) fs.watch on the per-user session store —
|
|
19
|
+
// every lifecycle transition lands as a sessions/<id>/session.json write; (2) fs.watch on the shared git
|
|
20
|
+
// dir's refs (+ packed-refs/HEAD) — a commit or merge moves a ref, so tree reshapes push instead of
|
|
21
|
+
// waiting out a poll; (3) a subscriber-gated ~2s poll of the CHEAP tmux session signature ([[sessions]])
|
|
22
|
+
// for liveness/activity, which never touch a file; (4) a delta-gated ~15s cold tick that rebuilds and
|
|
23
|
+
// diffs server-side, catching what no watcher sees (uncommitted worktree spec edits, forge issues) — ONE
|
|
24
|
+
// rebuild per tick total, replacing every open dashboard's own 15s full refetch. Plain mode without delta
|
|
25
|
+
// subscribers keeps its zero-build behavior: sources just fan out `board-changed`.
|
|
26
|
+
|
|
27
|
+
type Notify = () => void
|
|
28
|
+
type Frame = { event: string; data: string }
|
|
29
|
+
type DeltaSend = (frame: Frame) => void
|
|
30
|
+
const plainSubs = new Set<Notify>()
|
|
31
|
+
const deltaSubs = new Set<DeltaSend>()
|
|
32
|
+
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
33
|
+
|
|
34
|
+
// ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
|
|
35
|
+
// last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
|
|
36
|
+
// subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
|
|
37
|
+
// failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
|
|
38
|
+
let lastUnits: Units | null = null
|
|
39
|
+
let lastTag = ''
|
|
40
|
+
let lastFullFrame: Frame | null = null
|
|
41
|
+
let building = false
|
|
42
|
+
let dirty = false
|
|
43
|
+
|
|
44
|
+
async function rebuildAndBroadcast(): Promise<void> {
|
|
45
|
+
if (building) { dirty = true; return }
|
|
46
|
+
building = true
|
|
47
|
+
try {
|
|
48
|
+
do {
|
|
49
|
+
dirty = false
|
|
50
|
+
let board: unknown
|
|
51
|
+
// share the route's single-flight build ([[board-cache]]); fireChanged() already invalidated the
|
|
52
|
+
// cache, so this gets a fresh build (or joins one a concurrent poll already started).
|
|
53
|
+
try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
|
|
54
|
+
const boardJson = JSON.stringify(board)
|
|
55
|
+
const { units, ok } = unitize(board as Record<string, unknown>)
|
|
56
|
+
const tag = tagOf(units)
|
|
57
|
+
if (tag === lastTag) continue
|
|
58
|
+
const fullFrame: Frame = { event: 'board-full', data: `{"to":"${tag}","board":${boardJson}}` }
|
|
59
|
+
let frame = fullFrame
|
|
60
|
+
if (lastUnits && ok) {
|
|
61
|
+
const { set, del } = diffUnits(lastUnits, units)
|
|
62
|
+
const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
|
|
63
|
+
// guaranteed win: ship the patch only when it actually beats the snapshot
|
|
64
|
+
if (deltaData.length < fullFrame.data.length) frame = { event: 'board-delta', data: deltaData }
|
|
65
|
+
}
|
|
66
|
+
lastUnits = ok ? units : null
|
|
67
|
+
lastTag = tag
|
|
68
|
+
lastFullFrame = fullFrame
|
|
69
|
+
for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
|
|
70
|
+
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
71
|
+
} while (dirty)
|
|
72
|
+
} finally { building = false }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// a merge/launch/close touches several record files at once; collapse the burst into ONE signal. With
|
|
76
|
+
// delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same tag-moved
|
|
77
|
+
// gate — no spurious refetches); without them it stays the zero-build legacy notify.
|
|
78
|
+
function fireChanged(): void {
|
|
79
|
+
// invalidate the route's board cache ([[board-cache]]) on EVERY change signal, before the debounce guard
|
|
80
|
+
// — a plain-mode client that polls /api/board (no delta rebuild here) must still see fresh data on its
|
|
81
|
+
// next poll, and a delta rebuild below re-reads the same now-stale cache.
|
|
82
|
+
invalidateBoard()
|
|
83
|
+
if (debounce) return
|
|
84
|
+
debounce = setTimeout(() => {
|
|
85
|
+
debounce = null
|
|
86
|
+
if (deltaSubs.size) void rebuildAndBroadcast()
|
|
87
|
+
else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
|
|
88
|
+
}, 150)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
92
|
+
// for the one mutation the watchers structurally cannot see: /rename writes the worktree's `.session`
|
|
93
|
+
// (the name's ONE home, outside the watched store — [[session-rename]]). The route calls this instead of
|
|
94
|
+
// double-writing the name into the store: a notification problem gets a notification solution, never a
|
|
95
|
+
// second data home. Same debounced funnel as every other source.
|
|
96
|
+
export const notifyBoardChanged = (): void => fireChanged()
|
|
97
|
+
|
|
98
|
+
// ---- event source 1: the session store (lifecycle status writes) ----
|
|
99
|
+
let watcher: FSWatcher | null = null
|
|
100
|
+
function ensureWatcher(): void {
|
|
101
|
+
if (watcher) return
|
|
102
|
+
const root = sessionsRoot()
|
|
103
|
+
try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
|
|
104
|
+
try { watcher = watch(root, { recursive: true }, () => fireChanged()) } catch { watcher = null }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) ----
|
|
108
|
+
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
109
|
+
// for packed-refs rewrites and HEAD flips. Best-effort like every source: no watch → the cold tick covers.
|
|
110
|
+
let refsWatchers: FSWatcher[] | null = null
|
|
111
|
+
function ensureRefsWatcher(): void {
|
|
112
|
+
if (refsWatchers) return
|
|
113
|
+
refsWatchers = []
|
|
114
|
+
try {
|
|
115
|
+
const common = gitCommonDir()
|
|
116
|
+
try { refsWatchers.push(watch(join(common, 'refs'), { recursive: true }, () => fireChanged())) } catch { /* loose refs unwatched */ }
|
|
117
|
+
try { refsWatchers.push(watch(common, (_e, f) => { if (f === 'packed-refs' || f === 'HEAD') fireChanged() })) } catch { /* packed refs unwatched */ }
|
|
118
|
+
} catch { /* not a repo? the cold tick still covers */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---- event source 3: the tmux-derived signature (liveness + activity — never a file write) ----
|
|
122
|
+
let poller: ReturnType<typeof setInterval> | null = null
|
|
123
|
+
let lastSig = ''
|
|
124
|
+
function ensureLivePoll(): void {
|
|
125
|
+
if (poller) return
|
|
126
|
+
poller = setInterval(() => {
|
|
127
|
+
void sessionSignature().then((sig) => { if (sig !== lastSig) { lastSig = sig; fireChanged() } }).catch(() => {})
|
|
128
|
+
}, 2000)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- event source 4: the cold tick — the server-side replacement for every client's slow fallback poll.
|
|
132
|
+
// Rebuild+diff on a relaxed timer so what NO watcher sees (an uncommitted worktree spec edit, a forge
|
|
133
|
+
// issue refresh) still lands; an unchanged tag broadcasts nothing. Delta-gated: plain-only clients keep
|
|
134
|
+
// their own client-side fallback, so without delta subscribers this must not burn builds.
|
|
135
|
+
let coldTick: ReturnType<typeof setInterval> | null = null
|
|
136
|
+
function ensureColdTick(): void {
|
|
137
|
+
if (coldTick) return
|
|
138
|
+
coldTick = setInterval(() => { if (deltaSubs.size) void rebuildAndBroadcast() }, 15000)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stopSourcesIfIdle(): void {
|
|
142
|
+
if (plainSubs.size + deltaSubs.size > 0) return
|
|
143
|
+
if (poller) { clearInterval(poller); poller = null; lastSig = '' }
|
|
144
|
+
if (coldTick) { clearInterval(coldTick); coldTick = null }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// GET /api/board/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
|
|
148
|
+
// idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
|
|
149
|
+
// auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `board-full`, so the
|
|
150
|
+
// chain re-anchors with no client-side repair logic.
|
|
151
|
+
export function boardStream(c: Context) {
|
|
152
|
+
const delta = c.req.query('mode') === 'delta'
|
|
153
|
+
ensureWatcher()
|
|
154
|
+
ensureRefsWatcher()
|
|
155
|
+
return streamSSE(c, async (stream) => {
|
|
156
|
+
let aborted = false
|
|
157
|
+
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|
|
158
|
+
const notify: Notify = () => { void stream.writeSSE({ event: 'board-changed', data: 'x' }).catch(() => {}) }
|
|
159
|
+
if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
|
|
160
|
+
ensureLivePoll()
|
|
161
|
+
const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
|
|
162
|
+
stream.onAbort(() => { aborted = true; unsub() })
|
|
163
|
+
try {
|
|
164
|
+
await stream.writeSSE({ event: 'ready', data: 'x' })
|
|
165
|
+
if (delta) {
|
|
166
|
+
// seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
|
|
167
|
+
// then a fire so a connect during a quiet stretch converges to truly-current within one build.
|
|
168
|
+
if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
|
|
169
|
+
else void rebuildAndBroadcast()
|
|
170
|
+
}
|
|
171
|
+
while (!aborted) {
|
|
172
|
+
await stream.sleep(25000)
|
|
173
|
+
if (aborted) break
|
|
174
|
+
await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
175
|
+
}
|
|
176
|
+
} finally { unsub() }
|
|
177
|
+
})
|
|
178
|
+
}
|