spexcode 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +122 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +71 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +91 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +235 -1021
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
4
|
+
|
|
5
|
+
// @@@ delivery-queue - what a session still OWES its agent. The log ([[session-timeline]]) is the record and
|
|
6
|
+
// grows forever; this is the debt and is consumed, so it lives in its own small file whose resting state is
|
|
7
|
+
// EMPTY. Nothing here reads the log: an entry carries the text it will hand over, so history could be trimmed
|
|
8
|
+
// or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
|
|
9
|
+
// queue is only ever filled by an enqueue — which is why no backlog migration exists.
|
|
10
|
+
|
|
11
|
+
export type PendingMessage = { mid: string; text: string; from: string | null }
|
|
12
|
+
|
|
13
|
+
const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
|
|
14
|
+
|
|
15
|
+
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
16
|
+
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
17
|
+
// record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
|
|
18
|
+
// and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
|
|
19
|
+
// so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
|
|
20
|
+
const lockRoot = (): string => join(runtimeRoot(), '.delivery-locks')
|
|
21
|
+
const lockPath = (id: string): string => join(lockRoot(), `${id}.lock`)
|
|
22
|
+
|
|
23
|
+
const pause = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
24
|
+
|
|
25
|
+
async function acquire(id: string, timeoutMs: number): Promise<(() => void) | null> {
|
|
26
|
+
mkdirSync(lockRoot(), { recursive: true })
|
|
27
|
+
const path = lockPath(id), deadline = Date.now() + timeoutMs
|
|
28
|
+
for (;;) {
|
|
29
|
+
try {
|
|
30
|
+
const fd = openSync(path, 'wx')
|
|
31
|
+
writeSync(fd, String(process.pid))
|
|
32
|
+
closeSync(fd)
|
|
33
|
+
return () => { try { unlinkSync(path) } catch { /* a liveness reclaim already removed it */ } }
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
|
36
|
+
let owner = 0
|
|
37
|
+
try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
|
|
38
|
+
if (owner && owner !== process.pid) {
|
|
39
|
+
try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
|
|
40
|
+
}
|
|
41
|
+
// A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
|
|
42
|
+
// and the retry sweep will come back. Declining is not a lost message.
|
|
43
|
+
if (Date.now() >= deadline) return null
|
|
44
|
+
await pause(25)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function read(id: string): PendingMessage[] {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(queuePath(id), 'utf8')) as unknown
|
|
52
|
+
if (!Array.isArray(raw)) return []
|
|
53
|
+
return raw.filter((m): m is PendingMessage =>
|
|
54
|
+
!!m && typeof m === 'object'
|
|
55
|
+
&& typeof (m as PendingMessage).mid === 'string'
|
|
56
|
+
&& typeof (m as PendingMessage).text === 'string')
|
|
57
|
+
} catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
|
|
61
|
+
// one existsSync on the sweep's hot path.
|
|
62
|
+
function write(id: string, msgs: PendingMessage[]): void {
|
|
63
|
+
const path = queuePath(id)
|
|
64
|
+
if (!msgs.length) { try { unlinkSync(path) } catch { /* already gone */ } ; return }
|
|
65
|
+
mkdirSync(sessionStoreDir(id), { recursive: true })
|
|
66
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
67
|
+
writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n')
|
|
68
|
+
renameSync(tmp, path)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
|
|
72
|
+
// both, and the record is written first, so a crash between them leaves a message visible but undelivered —
|
|
73
|
+
// never delivered but unrecorded.
|
|
74
|
+
export function enqueue(id: string, msg: PendingMessage): void {
|
|
75
|
+
write(id, [...read(id), msg])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const pendingMessages = (id: string): PendingMessage[] => read(id)
|
|
79
|
+
|
|
80
|
+
export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
|
|
81
|
+
|
|
82
|
+
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
83
|
+
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
84
|
+
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
85
|
+
// one. Returns how many were handed over and how many are still owed.
|
|
86
|
+
export async function drain(
|
|
87
|
+
id: string,
|
|
88
|
+
insert: (msg: PendingMessage) => Promise<boolean>,
|
|
89
|
+
timeoutMs = 5_000,
|
|
90
|
+
): Promise<{ delivered: number; remaining: number }> {
|
|
91
|
+
const release = await acquire(id, timeoutMs)
|
|
92
|
+
if (!release) return { delivered: 0, remaining: read(id).length }
|
|
93
|
+
let delivered = 0
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
const queued = read(id)
|
|
97
|
+
if (!queued.length) return { delivered, remaining: 0 }
|
|
98
|
+
let ok = false
|
|
99
|
+
try { ok = await insert(queued[0]) } catch { ok = false }
|
|
100
|
+
if (!ok) return { delivered, remaining: queued.length }
|
|
101
|
+
// Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
|
|
102
|
+
// stale snapshot minus the head would silently drop it.
|
|
103
|
+
write(id, read(id).filter((m) => m.mid !== queued[0].mid))
|
|
104
|
+
delivered++
|
|
105
|
+
}
|
|
106
|
+
} finally { release() }
|
|
107
|
+
}
|
package/spec-cli/src/doctor.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// @@@ spex doctor - the opt-in, read-only project DIAGNOSIS surface ([[doctor]]). Bare doctor composes
|
|
2
|
-
// spec-health findings with the delivery audit: when a user launches their OWN agent with no SpexCode
|
|
3
|
-
// process in the launch, the workflow reaches it only through the files materialize() writes. The audit
|
|
4
|
-
// loops the same HARNESSES adapter materialize uses and catches missing delivery or duplicate discovery.
|
|
5
|
-
// `--contract` and `--conflicts` remain focused representations of that same diagnosis.
|
|
6
1
|
import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
|
|
7
2
|
import { join, dirname, basename } from 'node:path'
|
|
8
3
|
import { fileURLToPath } from 'node:url'
|
|
@@ -179,8 +174,6 @@ function healthReport(findings: HealthFinding[], adopted: boolean): string[] {
|
|
|
179
174
|
return lines
|
|
180
175
|
}
|
|
181
176
|
|
|
182
|
-
// @@@ contractText - the layer-2 payload: the composed `surface:system` bodies, the SAME join materialize()
|
|
183
|
-
// folds into each harness's contract file — so a BYOA agent reads byte-identical guidance.
|
|
184
177
|
function contractText(): { names: string[]; body: string } {
|
|
185
178
|
let cfgs: { name: string; body: string }[] = []
|
|
186
179
|
try { cfgs = loadSystemConfig().map((c) => ({ name: c.name, body: c.body })) } catch { /* tree-less cwd → empty */ }
|
|
@@ -223,15 +216,6 @@ function manifestScripts(text: string): string[] {
|
|
|
223
216
|
return [...out]
|
|
224
217
|
}
|
|
225
218
|
|
|
226
|
-
// @@@ double-delivery - the SILENT conflict on the OTHER axis from under-delivery: not "did the contract
|
|
227
|
-
// land?" but "did it land TWICE?". A self-launched agent can be reached by BOTH the loose native delivery
|
|
228
|
-
// materialize() writes into the worktree AND a `spexcode` plugin bundle the user installed independently
|
|
229
|
-
// (Claude marketplace) or a stale leftover — doubling every hook (dispatch.sh fires per copy), shadowing
|
|
230
|
-
// skills, confusing the `/` menu. We never sniff payload: every count is by IDENTITY STAMP — a shim's
|
|
231
|
-
// `dispatch.sh` command line (the hook-routing stamp, the same one cleanHarness keys on), a plugin.json
|
|
232
|
-
// `name:"spexcode"` (the bundle stamp), and our own materialized skill NAMES. Per harness we count, on three
|
|
233
|
-
// channels, how many spexcode-stamped copies reach the agent; any channel >1 is a double-delivery conflict.
|
|
234
|
-
|
|
235
219
|
// a plugin bundle dir found under a harness's plugins root, carrying a spexcode stamp.
|
|
236
220
|
type Bundle = { dir: string; name: string; scope: string; hooksToDispatch: boolean; skillsDir: string }
|
|
237
221
|
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// @@@ gateway auth - the ONE authorization mechanism of the multi-project gateway ([[gateway-hub]]).
|
|
2
|
-
// Everything about passwords and visitors lives HERE, at the gateway: the project backends stay loopback
|
|
3
|
-
// internal services that never see a credential, a cookie, or a visitor identity. Two signed scopes only:
|
|
4
|
-
// an ADMIN session grants /projects (management) plus every /p/:projectId route; a PROJECT session grants
|
|
5
|
-
// exactly its own /p/:projectId route. A project with no configured password is open; with no admin
|
|
6
|
-
// password, loopback may manage implicitly while non-loopback /projects stays locked.
|
|
7
|
-
//
|
|
8
|
-
// Secrets discipline: password VERIFIERS (scrypt, salted) live only in the per-user private store
|
|
9
|
-
// (~/.spexcode/gateway/auth.json, 0600) — never in a repo, never in a backend record, never plaintext.
|
|
10
|
-
// Session tokens are HMAC-signed claims under a random per-user secret, so they survive a gateway restart
|
|
11
|
-
// with no server-side session table, verified in constant time. Each verifier carries a random `gen` that
|
|
12
|
-
// rotates on every set/clear and is embedded in the tokens it authenticates — changing or clearing a
|
|
13
|
-
// password instantly invalidates every session it minted.
|
|
14
1
|
import { createHmac, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
15
2
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
3
|
import { dirname, join } from 'node:path'
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// @@@ public gateway - the internet face of `spex serve --public`. The supervisor (supervise.ts) and its
|
|
2
|
-
// Hono child stay bound to 127.0.0.1; THIS is the only listener on 0.0.0.0. It terminates TLS, gates every
|
|
3
|
-
// request behind one password (a designed login → signed cookie), serves the built dashboard, and reverse-
|
|
4
|
-
// proxies /api + the terminal WebSocket to the loopback supervisor. Loopback is the trust boundary (local
|
|
5
|
-
// agents hit the supervisor directly, no password); the gateway is the boundary crossed from outside.
|
|
6
1
|
import http from 'node:http'
|
|
7
2
|
import https from 'node:https'
|
|
8
3
|
import net from 'node:net'
|
|
@@ -17,10 +12,6 @@ import { loginPage } from './login-page.js'
|
|
|
17
12
|
import { listenOrExit } from './listen.js'
|
|
18
13
|
import { installConnectionReaper } from './reaper.js'
|
|
19
14
|
|
|
20
|
-
// @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
|
|
21
|
-
// chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
|
|
22
|
-
// is off (the supervisor then serves plain loopback, unchanged). process.argv carries the `spex serve …`
|
|
23
|
-
// flags since the supervisor runs in the same process as the CLI command.
|
|
24
15
|
export type PublicConfig = { password: string; tls: { cert: string; key: string } | null }
|
|
25
16
|
function argFlag(name: string): string | undefined {
|
|
26
17
|
const i = process.argv.indexOf(`--${name}`)
|
|
@@ -82,10 +73,6 @@ function selfSignedCert(): { cert: string; key: string } {
|
|
|
82
73
|
return { cert: readFileSync(certFile, 'utf8'), key: readFileSync(keyFile, 'utf8') }
|
|
83
74
|
}
|
|
84
75
|
|
|
85
|
-
// @@@ cookie auth - the gate is a designed login, NOT the browser's Basic dialog. The auth cookie is a
|
|
86
|
-
// keyed HMAC of a constant under a secret DERIVED from the password, so it (a) survives a restart with no
|
|
87
|
-
// server-side session store and (b) reveals nothing about the password. Verified in constant time. The same
|
|
88
|
-
// cookie authorises /api and the WebSocket upgrade — the browser sends it on the same-origin handshake.
|
|
89
76
|
const COOKIE = 'spex_auth'
|
|
90
77
|
function authToken(password: string): string {
|
|
91
78
|
const secret = createHmac('sha256', password).update('spexcode-public-gateway-v1').digest()
|
|
@@ -150,20 +137,11 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
150
137
|
return serveStatic(req, res, opts.distDir, url)
|
|
151
138
|
}
|
|
152
139
|
|
|
153
|
-
// server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
|
|
154
|
-
// public server in public mode, so it carries the SAME reaping as the child: the socket-level
|
|
155
|
-
// `installConnectionReaper` (reaper.ts), the single owner of the header/idle deadlines — it disables
|
|
156
|
-
// Node's own overlapping HTTP timeouts, which were measured to race and shadow it (issue #65), so no
|
|
157
|
-
// timeout options are passed here. Idle keep-alive / slow-loris / never-completing request only; the
|
|
158
|
-
// gated WS upgrade (handled below) is an active stream and exempt for its lifetime.
|
|
159
140
|
const server = secure
|
|
160
141
|
? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, handler)
|
|
161
142
|
: http.createServer(handler)
|
|
162
143
|
installConnectionReaper(server)
|
|
163
144
|
|
|
164
|
-
// @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
|
|
165
|
-
// it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
|
|
166
|
-
// upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
|
|
167
145
|
server.on('upgrade', (req, socket, head) => {
|
|
168
146
|
if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
|
|
169
147
|
const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSy
|
|
|
4
4
|
import { join, isAbsolute, resolve } from 'node:path'
|
|
5
5
|
import { createHash, randomBytes } from 'node:crypto'
|
|
6
6
|
import { projectRuntimeRoot } from './project-store.js'
|
|
7
|
+
import { rootSlots, touchRoot as touchRootLru } from './root-lru.js'
|
|
7
8
|
|
|
8
9
|
const US = '\x1f', RS = '\x1e'
|
|
9
10
|
|
|
@@ -152,9 +153,32 @@ export function isGitObjectId(root: string, value: string): boolean {
|
|
|
152
153
|
// Batch immutable object lookups used by the shared anchor/index path. Git accepts revision:path queries on
|
|
153
154
|
// batch-check, so dozens of rev-parse + cat-file children collapse into two bounded processes without
|
|
154
155
|
// changing the returned bytes or object ids.
|
|
155
|
-
|
|
156
|
+
//
|
|
157
|
+
// These are async for two reasons a synchronous execFileSync cannot serve once the caller batches its WHOLE
|
|
158
|
+
// invocation rather than one reading at a time. A sync child is invisible to the build's permit pool and its
|
|
159
|
+
// abort signal, so a watchdog abort could not kill it; and one build-wide `cat-file --batch` reading tens of
|
|
160
|
+
// megabytes would be a single uninterruptible stretch — exactly the /health-blocking shape [[graph-cache]]
|
|
161
|
+
// closed for the fs walks.
|
|
162
|
+
//
|
|
163
|
+
// Chunking is a real output bound, not ceremony: the async transport caps a child's stdout at GIT_MAX_BUFFER
|
|
164
|
+
// and overflow is a loud error. Revision rows are 41 bytes each, so only the blob read (payload-sized) needs
|
|
165
|
+
// a chunk; the cap is on COUNT because sizes are unknown until git answers, and it is set so an ordinary
|
|
166
|
+
// source corpus never approaches the byte ceiling.
|
|
167
|
+
const BATCH_BLOB_CHUNK = 256
|
|
168
|
+
const BATCH_BLOB_MAX_BUFFER = 1 << 26
|
|
169
|
+
async function batchBuffer(args: string[], input: string, maxBuffer?: number): Promise<Buffer> {
|
|
170
|
+
const env = { ...process.env }
|
|
171
|
+
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
172
|
+
try { return (await execGitForCaller(args, env, maxBuffer, input)).stdout }
|
|
173
|
+
catch (error: any) {
|
|
174
|
+
if (error?.name === 'AbortError') throw error
|
|
175
|
+
warnIfTimedOut(error, args)
|
|
176
|
+
throw new Error(`git ${args.slice(2, 5).join(' ')} failed: ${String(error?.stderr || error?.message || 'unknown git error').trim()}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export async function batchRevisionOids(root: string, revisions: string[]): Promise<(string | null)[]> {
|
|
156
180
|
if (!revisions.length) return []
|
|
157
|
-
const out =
|
|
181
|
+
const out = (await batchBuffer(['-C', root, 'cat-file', '--batch-check=%(objectname)'], revisions.join('\n') + '\n')).toString('utf8')
|
|
158
182
|
const lines = out.split('\n')
|
|
159
183
|
if (lines.length - 1 !== revisions.length) throw new Error(`git cat-file --batch-check returned ${lines.length - 1} rows for ${revisions.length} revisions`)
|
|
160
184
|
return revisions.map((revision, index) => {
|
|
@@ -164,25 +188,28 @@ export function batchRevisionOids(root: string, revisions: string[]): (string |
|
|
|
164
188
|
throw new Error(`git cat-file --batch-check returned '${value}' for ${revision}`)
|
|
165
189
|
})
|
|
166
190
|
}
|
|
167
|
-
export function batchBlobTexts(root: string, oids: string[]): Map<string, string
|
|
191
|
+
export async function batchBlobTexts(root: string, oids: string[]): Promise<Map<string, string>> {
|
|
168
192
|
const unique = [...new Set(oids.filter(Boolean))]
|
|
169
193
|
const files = new Map<string, string>()
|
|
170
194
|
if (!unique.length) return files
|
|
171
195
|
for (const oid of unique) if (!isGitObjectId(root, oid)) throw new Error(`invalid object id '${oid}'`)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
for (let cursor = 0; cursor < unique.length; cursor += BATCH_BLOB_CHUNK) {
|
|
197
|
+
const chunk = unique.slice(cursor, cursor + BATCH_BLOB_CHUNK)
|
|
198
|
+
const out = await batchBuffer(['-C', root, 'cat-file', '--batch'], chunk.join('\n') + '\n', BATCH_BLOB_MAX_BUFFER)
|
|
199
|
+
let offset = 0
|
|
200
|
+
for (const oid of chunk) {
|
|
201
|
+
const newline = out.indexOf(10, offset)
|
|
202
|
+
if (newline < 0) throw new Error(`git cat-file --batch ended before ${oid}`)
|
|
203
|
+
const header = out.subarray(offset, newline).toString('utf8')
|
|
204
|
+
const size = Number(header.match(/ blob (\d+)$/)?.[1])
|
|
205
|
+
if (!header.startsWith(`${oid} blob `) || !Number.isFinite(size)) throw new Error(`git cat-file --batch returned '${header}' for ${oid}`) // dead-words-ok: Git object protocol type
|
|
206
|
+
const start = newline + 1, end = start + size
|
|
207
|
+
if (end >= out.length || out[end] !== 0x0a) throw new Error(`git cat-file --batch truncated object ${oid}`)
|
|
208
|
+
files.set(oid, out.subarray(start, end).toString('utf8'))
|
|
209
|
+
offset = end + 1
|
|
210
|
+
}
|
|
211
|
+
if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
|
|
184
212
|
}
|
|
185
|
-
if (offset !== out.length) throw new Error(`git cat-file --batch returned ${out.length - offset} unexpected trailing bytes`)
|
|
186
213
|
return files
|
|
187
214
|
}
|
|
188
215
|
|
|
@@ -229,7 +256,9 @@ export function treeFileText(root: string, tip: string, path: string): string |
|
|
|
229
256
|
catch { return null }
|
|
230
257
|
}
|
|
231
258
|
|
|
232
|
-
|
|
259
|
+
// stdout stays a Buffer to the transport's edge: `cat-file --batch` frames each payload by BYTE length, so
|
|
260
|
+
// a decode before framing mis-slices every object after the first multi-byte character. Text callers decode.
|
|
261
|
+
type GitExec = { stdout: Buffer; stderr: string }
|
|
233
262
|
|
|
234
263
|
// execFile's AbortSignal kills only its direct child. A wedged adapter may have descendants (the
|
|
235
264
|
// deterministic tests use a shell + sleep), so async git runs in their own process group and abort/timeout
|
|
@@ -265,14 +294,21 @@ function execGit(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSignal, m
|
|
|
265
294
|
child.once('close', (code, childSignal) => {
|
|
266
295
|
clearTimeout(timer)
|
|
267
296
|
signal?.removeEventListener('abort', onAbort)
|
|
268
|
-
const result = { stdout: Buffer.concat(stdout)
|
|
297
|
+
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
|
|
269
298
|
if (code === 0 && !aborted && !timedOut && !overflow && !spawnError) { resolve(result); return }
|
|
270
299
|
const error: any = spawnError ?? new Error(overflow
|
|
271
300
|
? `git output exceeded ${maxBuffer} bytes`
|
|
272
301
|
: `git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
273
|
-
|
|
302
|
+
// @@@ a spawn failure keeps its OWN cause - a child that never started still emits `close`, with the
|
|
303
|
+
// negated errno as its code (EACCES arrives as -13). Overwriting the spawn error's `'EACCES'`/`'ENOENT'`
|
|
304
|
+
// with that number turns "git could not RUN" into "git ran and exited", and every caller that separates
|
|
305
|
+
// the two reads the second as a real git answer: the freshness batch concluded "the anchor object is
|
|
306
|
+
// unreadable", session create concluded "the branch does not exist". A machine where git is missing or
|
|
307
|
+
// unexecutable would have been told, silently, that the thing it asked about is absent.
|
|
308
|
+
if (overflow) error.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
309
|
+
else if (!spawnError) error.code = code
|
|
274
310
|
error.signal = childSignal
|
|
275
|
-
error.stdout = result.stdout
|
|
311
|
+
error.stdout = result.stdout.toString('utf8')
|
|
276
312
|
error.stderr = result.stderr
|
|
277
313
|
if (aborted) error.name = 'AbortError'
|
|
278
314
|
if (timedOut) error.spexcodeGitTimeout = true
|
|
@@ -326,12 +362,12 @@ function execGitStream(args: string[], env: NodeJS.ProcessEnv, signal?: AbortSig
|
|
|
326
362
|
child.on('close', (code, childSignal) => {
|
|
327
363
|
if (settled) return
|
|
328
364
|
settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort)
|
|
329
|
-
const result = { stdout: Buffer.concat(stdout)
|
|
365
|
+
const result = { stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString('utf8') }
|
|
330
366
|
if (code === 0 && !aborted && !timedOut) { resolve(result); return }
|
|
331
367
|
const error: any = new Error(`git exited with ${code ?? childSignal ?? 'unknown status'}`)
|
|
332
368
|
error.code = code
|
|
333
369
|
error.signal = childSignal
|
|
334
|
-
error.stdout = result.stdout
|
|
370
|
+
error.stdout = result.stdout.toString('utf8')
|
|
335
371
|
error.stderr = result.stderr
|
|
336
372
|
if (aborted) error.name = 'AbortError'
|
|
337
373
|
if (timedOut) error.spexcodeGitTimeout = true
|
|
@@ -353,7 +389,7 @@ export async function gitA(args: string[], input?: string): Promise<string> {
|
|
|
353
389
|
const context = inheritedContext()
|
|
354
390
|
try {
|
|
355
391
|
const { stdout } = await execGitForCaller(args, env, undefined, input)
|
|
356
|
-
return stdout
|
|
392
|
+
return stdout.toString('utf8')
|
|
357
393
|
} catch (e: any) {
|
|
358
394
|
if (context?.signal.aborted || e?.name === 'AbortError') throw e
|
|
359
395
|
warnIfTimedOut(e, args); return ''
|
|
@@ -797,7 +833,7 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
|
|
|
797
833
|
const context = inheritedContext()
|
|
798
834
|
try {
|
|
799
835
|
const { stdout, stderr } = await execGitForCaller(args, env)
|
|
800
|
-
return { ok: true, stdout, stderr }
|
|
836
|
+
return { ok: true, stdout: stdout.toString('utf8'), stderr }
|
|
801
837
|
} catch (e: any) {
|
|
802
838
|
if (context?.signal.aborted || e?.name === 'AbortError') throw e
|
|
803
839
|
warnIfTimedOut(e, args)
|
|
@@ -809,7 +845,7 @@ export async function gitTry(args: string[], options: { indexFile?: string } = {
|
|
|
809
845
|
export async function gitRequiredA(args: string[], purpose: string): Promise<string> {
|
|
810
846
|
const env = { ...process.env }
|
|
811
847
|
delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_OBJECT_DIRECTORY
|
|
812
|
-
try { return (await execGitStreamForCaller(args, env)).stdout }
|
|
848
|
+
try { return (await execGitStreamForCaller(args, env)).stdout.toString('utf8') }
|
|
813
849
|
catch (error: any) {
|
|
814
850
|
if (error?.name === 'AbortError') throw error
|
|
815
851
|
warnIfTimedOut(error, args)
|
|
@@ -990,7 +1026,7 @@ const indexCache = new Map<string, Promise<HistoryIndex>>()
|
|
|
990
1026
|
const indexRoots = new Map<string, string>()
|
|
991
1027
|
const driftRoots = new Map<string, string>()
|
|
992
1028
|
const driftIdxCache = new Map<string, Promise<DriftIndex>>()
|
|
993
|
-
const INDEX_ROOT_SLOTS =
|
|
1029
|
+
const INDEX_ROOT_SLOTS = rootSlots(process.env.SPEXCODE_INDEX_CACHE_ROOTS, 32)
|
|
994
1030
|
|
|
995
1031
|
function rootKey(root: string): string { return resolve(root) }
|
|
996
1032
|
|
|
@@ -999,23 +1035,9 @@ function indexCacheKey(root: string, head: string): string { return `${eventCach
|
|
|
999
1035
|
// A project-namespaced ledger path plus HEAD identifies immutable index contents. Its path is scoped to the
|
|
1000
1036
|
// common Git store and interpretation state, so linked worktrees share while independent same-HEAD clones do not.
|
|
1001
1037
|
// The checkout root owns which live view is useful; the root bound keeps closed worktrees from leaking.
|
|
1038
|
+
// The bookkeeping itself is [[root-lru]]'s — this only names the store and the bound.
|
|
1002
1039
|
function touchRoot(roots: Map<string, string>, cache: Map<string, Promise<unknown>>, root: string, cacheKey: string): void {
|
|
1003
|
-
|
|
1004
|
-
const previous = roots.get(key)
|
|
1005
|
-
if (previous !== cacheKey) {
|
|
1006
|
-
roots.set(key, cacheKey)
|
|
1007
|
-
if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
|
|
1008
|
-
} else {
|
|
1009
|
-
roots.delete(key)
|
|
1010
|
-
roots.set(key, cacheKey)
|
|
1011
|
-
}
|
|
1012
|
-
while (roots.size > INDEX_ROOT_SLOTS) {
|
|
1013
|
-
const oldest = roots.keys().next().value as string | undefined
|
|
1014
|
-
if (oldest === undefined) break
|
|
1015
|
-
const oldHead = roots.get(oldest)
|
|
1016
|
-
roots.delete(oldest)
|
|
1017
|
-
if (oldHead && ![...roots.values()].includes(oldHead)) cache.delete(oldHead)
|
|
1018
|
-
}
|
|
1040
|
+
touchRootLru(roots, cache, rootKey(root), cacheKey, INDEX_ROOT_SLOTS)
|
|
1019
1041
|
}
|
|
1020
1042
|
|
|
1021
1043
|
function dropFailed(cache: Map<string, Promise<unknown>>, head: string, promise: Promise<unknown>): void {
|
|
@@ -1804,9 +1826,10 @@ export function resetHistoryCachesForTests(): void {
|
|
|
1804
1826
|
eventPathMemo.clear(); gitObjectFormatMemo.clear()
|
|
1805
1827
|
}
|
|
1806
1828
|
export function historyEventCachePathForTests(root: string): string { return eventCacheLocation(root).path }
|
|
1807
|
-
//
|
|
1808
|
-
//
|
|
1809
|
-
//
|
|
1829
|
+
// @@@ one ordinary ancestry memo, with a batch entrance - every closure below has exactly the same dense
|
|
1830
|
+
// bitset shape in idx.anc. A caller that already knows several bases can prime them in one child-to-parent
|
|
1831
|
+
// topology pass; the short-lived frontier is released before returning. Unknown or ad-hoc revisions retain
|
|
1832
|
+
// this direct parent DFS, so neither path introduces a second reachability representation or persistent fact.
|
|
1810
1833
|
// undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
|
|
1811
1834
|
// ref) — callers apply their own conservative rule to that "can't prove" case.
|
|
1812
1835
|
export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
|
|
@@ -1830,13 +1853,81 @@ export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefine
|
|
|
1830
1853
|
idx.anc.set(sha, bits)
|
|
1831
1854
|
return bits
|
|
1832
1855
|
}
|
|
1856
|
+
|
|
1857
|
+
// Fill the existing ancestry memo for a known roster in one child-before-parent topology pass. At each
|
|
1858
|
+
// commit the transient row names requested descendants; emitting those bits into the ordinary closures makes
|
|
1859
|
+
// the resulting bytes identical to calling ancestorsOf() independently for every requested SHA.
|
|
1860
|
+
export function primeAncestorClosures(idx: DriftIndex, shas: Iterable<string>): void {
|
|
1861
|
+
const endpoints = [...new Set(shas)].filter((sha) => !idx.anc.has(sha) && idx.ord.has(sha))
|
|
1862
|
+
if (!endpoints.length) return
|
|
1863
|
+
const count = idx.ord.size
|
|
1864
|
+
const width = (endpoints.length + 7) >> 3
|
|
1865
|
+
const closureWidth = (count + 7) >> 3
|
|
1866
|
+
const endpointAt = new Map(endpoints.map((sha, position) => [sha, position]))
|
|
1867
|
+
const hashAt = new Array<string>(count)
|
|
1868
|
+
for (const [hash, position] of idx.ord) hashAt[position] = hash
|
|
1869
|
+
|
|
1870
|
+
// Every row waits until all of its children have contributed. Parents outside the walked topology are
|
|
1871
|
+
// deliberately absent: they are the same shallow boundary ancestorsOf() stops at.
|
|
1872
|
+
const childrenLeft = new Int32Array(count)
|
|
1873
|
+
for (const parents of idx.parents.values()) for (const parent of parents) {
|
|
1874
|
+
const position = idx.ord.get(parent)
|
|
1875
|
+
if (position !== undefined) childrenLeft[position]++
|
|
1876
|
+
}
|
|
1877
|
+
const ready: number[] = []
|
|
1878
|
+
for (let position = 0; position < count; position++) if (childrenLeft[position] === 0) ready.push(position)
|
|
1879
|
+
|
|
1880
|
+
const closures = endpoints.map(() => new Uint8Array(closureWidth))
|
|
1881
|
+
const rows = new Map<number, Uint8Array>(), pool: Uint8Array[] = []
|
|
1882
|
+
const rowFor = (position: number): Uint8Array => {
|
|
1883
|
+
const existing = rows.get(position)
|
|
1884
|
+
if (existing) return existing
|
|
1885
|
+
const row = pool.pop() ?? new Uint8Array(width)
|
|
1886
|
+
rows.set(position, row)
|
|
1887
|
+
return row
|
|
1888
|
+
}
|
|
1889
|
+
let visited = 0
|
|
1890
|
+
while (ready.length) {
|
|
1891
|
+
const position = ready.pop()!
|
|
1892
|
+
visited++
|
|
1893
|
+
const row = rowFor(position)
|
|
1894
|
+
rows.delete(position)
|
|
1895
|
+
const endpoint = endpointAt.get(hashAt[position])
|
|
1896
|
+
if (endpoint !== undefined) row[endpoint >> 3] |= 1 << (endpoint & 7)
|
|
1897
|
+
for (let byte = 0; byte < width; byte++) {
|
|
1898
|
+
let pending = row[byte]
|
|
1899
|
+
while (pending) {
|
|
1900
|
+
const bit = 31 - Math.clz32(pending & -pending)
|
|
1901
|
+
closures[(byte << 3) + bit][position >> 3] |= 1 << (position & 7)
|
|
1902
|
+
pending &= pending - 1
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
for (const parent of idx.parents.get(hashAt[position]) ?? []) {
|
|
1906
|
+
const parentPosition = idx.ord.get(parent)
|
|
1907
|
+
if (parentPosition === undefined) continue
|
|
1908
|
+
const target = rowFor(parentPosition)
|
|
1909
|
+
for (let byte = 0; byte < width; byte++) target[byte] |= row[byte]
|
|
1910
|
+
if (--childrenLeft[parentPosition] === 0) ready.push(parentPosition)
|
|
1911
|
+
}
|
|
1912
|
+
row.fill(0)
|
|
1913
|
+
pool.push(row)
|
|
1914
|
+
}
|
|
1915
|
+
if (visited !== count)
|
|
1916
|
+
throw new Error(`cannot prime ancestry closures: topology yielded ${visited} of ${count} reachable commits`)
|
|
1917
|
+
for (let position = 0; position < endpoints.length; position++) idx.anc.set(endpoints[position], closures[position])
|
|
1918
|
+
}
|
|
1833
1919
|
export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
|
|
1834
1920
|
const o = idx.ord.get(sha)
|
|
1835
1921
|
return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
|
|
1836
1922
|
}
|
|
1837
1923
|
|
|
1924
|
+
// @@@ reachability is membership, not a closure - `ancestorsOf` returns undefined for EXACTLY the shas
|
|
1925
|
+
// absent from `idx.ord` (both writers of `idx.anc` gate on ord: the single-sha path after its ord lookup
|
|
1926
|
+
// succeeds, the batch after an explicit `ord.has` filter), so asking it here answered a hash-table
|
|
1927
|
+
// question by walking the whole parent DAG and allocating an ord.size-wide bitset per distinct sha —
|
|
1928
|
+
// per eval reading, on every board build.
|
|
1838
1929
|
export function commitReachable(idx: DriftIndex, sha: string): boolean {
|
|
1839
|
-
return
|
|
1930
|
+
return idx.ord.has(sha)
|
|
1840
1931
|
}
|
|
1841
1932
|
|
|
1842
1933
|
// the valid Spec-OK coverage for a node's version commit: `sinceHash` is the node's OWN latest version,
|
|
@@ -1891,10 +1982,22 @@ export function pathRangeEvents(idx: DriftIndex, sinceHash: string, path: string
|
|
|
1891
1982
|
})
|
|
1892
1983
|
}
|
|
1893
1984
|
|
|
1894
|
-
|
|
1985
|
+
// @@@ eventsSince - THE meaning of "this path changed since <sha>", in one place. A commit touching `path`
|
|
1986
|
+
// lies in `sha..HEAD` exactly when it is NOT an ancestor of `sha` — true DAG reachability, wherever a
|
|
1987
|
+
// date-ordered log happens to place it. `null` is the honest third answer: the anchor commit is not reachable
|
|
1988
|
+
// (folded, rebased, cherry-picked away), so ancestry cannot testify at all and the caller must decide what to
|
|
1989
|
+
// do about that — the spec layer reports no window, the eval layer falls back to comparing content.
|
|
1990
|
+
// Callers add their OWN layer's decoration on top (ack cover is spec-only; the content probe is eval-only);
|
|
1991
|
+
// what none of them may do is restate the reachability rule, which is how it came to exist four times.
|
|
1992
|
+
export function eventsSince(idx: DriftIndex, sinceHash: string, path: string): DriftPathEvent[] | null {
|
|
1895
1993
|
const base = ancestorsOf(idx, sinceHash)
|
|
1896
1994
|
if (!base) return null
|
|
1897
|
-
|
|
1995
|
+
return pathEvents(idx, path).filter((event) => !inAncestors(idx, base, event.commit))
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
export function driftPathWindow(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] | null {
|
|
1999
|
+
const events = eventsSince(idx, sinceHash, path)
|
|
2000
|
+
if (!events) return null
|
|
1898
2001
|
const cover = ackCoverFor(idx, sinceHash, nodeId)
|
|
1899
2002
|
return events.filter((event) => !cover.some((a) => inAncestors(idx, a, event.commit))
|
|
1900
2003
|
&& !selfAckCovers(idx, sinceHash, event.commit, nodeId))
|
package/spec-cli/src/graph.ts
CHANGED
|
@@ -2,10 +2,10 @@ import { loadSpecs, deriveStatus } from './specs.js'
|
|
|
2
2
|
import { resolveLayout } from './layout.js'
|
|
3
3
|
import { listSessions } from './sessions.js'
|
|
4
4
|
import { repoRoot, driftIndex, historyIndex } from './git.js'
|
|
5
|
-
import { residentForgeState } from '../../spec-forge/src/resident.js'
|
|
5
|
+
import { residentForgeRevision, residentForgeState } from '../../spec-forge/src/resident.js'
|
|
6
6
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
7
7
|
import { boardThreads } from './issues.js'
|
|
8
|
-
import { evalContext,
|
|
8
|
+
import { evalContext, evalTimelines } from '../../spec-eval/src/evaltab.js'
|
|
9
9
|
import { evalNodesAsync } from '../../spec-eval/src/scenarios.js'
|
|
10
10
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
11
11
|
import { sessionEvalProjections } from '../../spec-eval/src/sessioneval.js'
|
|
@@ -123,7 +123,9 @@ export async function buildBoard() {
|
|
|
123
123
|
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
124
124
|
// ONE store walk yielding both halves ([[issues]] boardThreads): the ISSUE surfaces get the split
|
|
125
125
|
// population, the freshness carrier gets the whole store.
|
|
126
|
-
const
|
|
126
|
+
const forgeState = residentForgeState()
|
|
127
|
+
const forgeRevision = residentForgeRevision()
|
|
128
|
+
const { issues: merged, stamp: issuesStamp } = boardThreads({ host: resolveForgeHost(), state: forgeState }, nodes.map((n) => n.id))
|
|
127
129
|
// `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread — noded or nodeless,
|
|
128
130
|
// both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
|
|
129
131
|
// scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
|
|
@@ -149,15 +151,16 @@ export async function buildBoard() {
|
|
|
149
151
|
// eval-file walk rides fs/promises ([[graph-cache]]) so it yields the event loop instead of stalling /health.
|
|
150
152
|
const ynodes = await evalNodesAsync(root)
|
|
151
153
|
const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
+
const timelines = await evalTimelines(nodes.map((n) => n.id), ectx)
|
|
155
|
+
const evalReviewNodes = timelines.map((tl, index) => {
|
|
156
|
+
const n = nodes[index]
|
|
154
157
|
if (!tl.hasEvalFile) return null
|
|
155
158
|
const latest = latestPerScenario(tl.readings)
|
|
156
159
|
n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(tl.scenarios, latest) }
|
|
157
160
|
return { id: n.id, hue: n.hue, scenarios: tl.scenarios, evals: latest, readings: tl.readings }
|
|
158
|
-
})
|
|
161
|
+
}).filter((node): node is NonNullable<typeof node> => node !== null)
|
|
159
162
|
|
|
160
|
-
publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes })
|
|
163
|
+
publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes, forgeRevision })
|
|
161
164
|
|
|
162
165
|
const opsByPath: Record<string, any[]> = {}
|
|
163
166
|
opWts.forEach((w) => { opsByPath[w.path] = w.ops })
|
|
@@ -198,3 +201,21 @@ export async function spliceSessions(prev: Awaited<ReturnType<typeof buildBoard>
|
|
|
198
201
|
}))
|
|
199
202
|
return { ...prev, sessions: sess }
|
|
200
203
|
}
|
|
204
|
+
|
|
205
|
+
// A full producer may finish after the session lane has already shown a newer row. Reuse that published
|
|
206
|
+
// projection on the full topology without another store read: topology owns the current per-path ops, while
|
|
207
|
+
// the published row owns lifecycle/eval fields. This is deliberately synchronous so a full completion never
|
|
208
|
+
// waits for a quiet session store.
|
|
209
|
+
export function rebasePublishedSessions(
|
|
210
|
+
topology: Awaited<ReturnType<typeof buildBoard>>,
|
|
211
|
+
published: Awaited<ReturnType<typeof buildBoard>>,
|
|
212
|
+
): Awaited<ReturnType<typeof buildBoard>> {
|
|
213
|
+
const opsByPath: Record<string, any[]> = {}
|
|
214
|
+
for (const session of topology.sessions) opsByPath[session.source] = session.ops
|
|
215
|
+
const sessions = published.sessions.map((session) => ({
|
|
216
|
+
...session,
|
|
217
|
+
source: session.path,
|
|
218
|
+
ops: rowOps(session, opsByPath),
|
|
219
|
+
}))
|
|
220
|
+
return { ...topology, sessions }
|
|
221
|
+
}
|