spexcode 0.5.8 → 0.5.9
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 +144 -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/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 +70 -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 +97 -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 +207 -1022
- 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 +43 -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
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
|
+
}
|