spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- 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 +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { listSessions } from './sessions.js'
|
|
3
3
|
import { getBoard, getBoardForForgeRevision } from './graphCache.js'
|
|
4
|
-
import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
4
|
+
import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
5
5
|
import { evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
6
6
|
import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
|
|
7
7
|
import { issueStores as issueStoresForReview } from './issues.js'
|
|
@@ -42,6 +42,9 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
|
|
|
42
42
|
|
|
43
43
|
export type EvalDetailReview = {
|
|
44
44
|
scope: string | null
|
|
45
|
+
requestedScope: string | null
|
|
46
|
+
scopeFallback: 'trunk' | null
|
|
47
|
+
availability: 'measured' | 'unmeasured' | 'missing'
|
|
45
48
|
selected: ReviewItem | null
|
|
46
49
|
history: ReviewItem[]
|
|
47
50
|
neighbors: {
|
|
@@ -56,6 +59,16 @@ export type EvalDetailReview = {
|
|
|
56
59
|
evalRevision?: SessionEvals['evalRevision']
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
type EvalDetailMetadata = {
|
|
63
|
+
scope?: string | null
|
|
64
|
+
requestedScope?: string | null
|
|
65
|
+
scopeFallback?: 'trunk' | null
|
|
66
|
+
summary?: SessionEvals['summary']
|
|
67
|
+
evalRevision?: SessionEvals['evalRevision']
|
|
68
|
+
// the scope's whole measured sequence, when `items` deliberately holds only the rendered window
|
|
69
|
+
sequence?: { node: string; scenario: string }[]
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
const revisionOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
60
73
|
|
|
61
74
|
export function reviewPageNumber(value: unknown): number {
|
|
@@ -113,7 +126,7 @@ export function paginateReview<T extends ReviewItem>(
|
|
|
113
126
|
pageCount,
|
|
114
127
|
prev: page > 1 ? page - 1 : null,
|
|
115
128
|
next: page < pageCount || overflow ? page + 1 : null,
|
|
116
|
-
revision: revisionOf(revisionInputs),
|
|
129
|
+
revision: revisionOf({ page, source: revisionInputs }),
|
|
117
130
|
...responseModel(model),
|
|
118
131
|
}
|
|
119
132
|
}
|
|
@@ -217,15 +230,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
|
217
230
|
|
|
218
231
|
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
219
232
|
|
|
220
|
-
function evalNeighbor(item: any): EvalNeighbor {
|
|
233
|
+
function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
|
|
221
234
|
return {
|
|
222
235
|
node: String(item.node),
|
|
223
236
|
scenario: String(item.scenario),
|
|
224
|
-
state:
|
|
237
|
+
state: stateOf(item),
|
|
225
238
|
}
|
|
226
239
|
}
|
|
227
240
|
|
|
228
|
-
|
|
241
|
+
// `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
|
|
242
|
+
// was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
|
|
243
|
+
// states cover the selected row and its window — which is exactly why the state is looked up rather than
|
|
244
|
+
// carried: a row this response does not render contributes its POSITION and nothing else.
|
|
245
|
+
export function boundedEvalNeighbors(
|
|
246
|
+
sequence: { node: string; scenario: string }[],
|
|
247
|
+
node: string,
|
|
248
|
+
scenario: string,
|
|
249
|
+
stateOf: (row: { node: string; scenario: string }) => string,
|
|
250
|
+
want = 5,
|
|
251
|
+
) {
|
|
252
|
+
const items = sequence
|
|
229
253
|
const key = `${node}\0${scenario}`
|
|
230
254
|
const index = items.findIndex((item) => evalItemKey(item) === key)
|
|
231
255
|
if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
|
|
@@ -235,8 +259,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
|
|
|
235
259
|
const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
|
|
236
260
|
const prevN = Math.min(before, take - nextN)
|
|
237
261
|
return {
|
|
238
|
-
prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
|
|
239
|
-
next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
|
|
262
|
+
prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
|
|
263
|
+
next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
|
|
240
264
|
total: items.length,
|
|
241
265
|
index,
|
|
242
266
|
order: 'default' as const,
|
|
@@ -248,59 +272,102 @@ export function projectEvalDetail(
|
|
|
248
272
|
historySource: ReviewItem[],
|
|
249
273
|
node: string,
|
|
250
274
|
scenario: string,
|
|
251
|
-
metadata:
|
|
275
|
+
metadata: EvalDetailMetadata = {},
|
|
252
276
|
): EvalDetailReview {
|
|
253
277
|
const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
254
278
|
const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
|
|
279
|
+
const availability = selected
|
|
280
|
+
? 'measured'
|
|
281
|
+
: items.some((item: any) => evalItemKey(item) === `${node}\0${scenario}` && item.filterKind === EVAL_FILTER_KIND.BLIND)
|
|
282
|
+
? 'unmeasured'
|
|
283
|
+
: 'missing'
|
|
255
284
|
const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
|
|
256
|
-
const
|
|
285
|
+
const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
|
|
286
|
+
const sequence = metadata.sequence
|
|
287
|
+
?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
|
|
288
|
+
const neighbors = boundedEvalNeighbors(sequence, node, scenario,
|
|
289
|
+
(row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
|
|
257
290
|
const scope = metadata.scope ?? null
|
|
291
|
+
const requestedScope = metadata.requestedScope ?? scope
|
|
292
|
+
const scopeFallback = metadata.scopeFallback ?? null
|
|
258
293
|
return {
|
|
259
294
|
scope,
|
|
295
|
+
requestedScope,
|
|
296
|
+
scopeFallback,
|
|
297
|
+
availability,
|
|
260
298
|
selected,
|
|
261
299
|
history,
|
|
262
300
|
neighbors,
|
|
263
|
-
revision: revisionOf({ scope, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
|
|
301
|
+
revision: revisionOf({ scope, requestedScope, scopeFallback, availability, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
|
|
264
302
|
...(metadata.summary ? { summary: metadata.summary } : {}),
|
|
265
303
|
...(metadata.evalRevision ? { evalRevision: metadata.evalRevision } : {}),
|
|
266
304
|
}
|
|
267
305
|
}
|
|
268
306
|
|
|
269
|
-
|
|
307
|
+
// the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
|
|
308
|
+
// and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
|
|
309
|
+
// actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
|
|
310
|
+
export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
|
|
311
|
+
return order.filter((row) => row.ts)
|
|
312
|
+
.sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
313
|
+
|| a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
|
|
314
|
+
.map((row) => ({ node: row.node, scenario: row.scenario }))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
|
|
318
|
+
// contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
|
|
319
|
+
export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
|
|
320
|
+
const sequence = measuredSequence(order)
|
|
321
|
+
const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
|
|
322
|
+
if (index < 0) return [node]
|
|
323
|
+
return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function trunkEvalDetailReview(node: string, scenario: string, metadata: EvalDetailMetadata = {}): Promise<EvalDetailReview> {
|
|
327
|
+
await getBoard()
|
|
328
|
+
const snapshot = readReviewSnapshot()
|
|
329
|
+
const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
|
|
330
|
+
return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario, metadata)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview> {
|
|
270
334
|
if (scope) {
|
|
271
|
-
|
|
272
|
-
|
|
335
|
+
// A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
|
|
336
|
+
// So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
|
|
337
|
+
// a build that finds a full cached model ignores the pick and answers from it instead.
|
|
338
|
+
const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
|
|
339
|
+
if (!model) return trunkEvalDetailReview(node, scenario, { requestedScope: scope, scopeFallback: 'trunk' })
|
|
273
340
|
const sourceNode = model.nodes.find((candidate) => candidate.id === node)
|
|
274
341
|
return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
|
|
275
342
|
scope,
|
|
343
|
+
requestedScope: scope,
|
|
276
344
|
summary: model.summary,
|
|
277
345
|
evalRevision: model.evalRevision,
|
|
346
|
+
...(model.order ? { sequence: measuredSequence(model.order) } : {}),
|
|
278
347
|
})
|
|
279
348
|
}
|
|
280
|
-
|
|
281
|
-
const snapshot = readReviewSnapshot()
|
|
282
|
-
const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
|
|
283
|
-
return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario)
|
|
349
|
+
return trunkEvalDetailReview(node, scenario)
|
|
284
350
|
}
|
|
285
351
|
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
352
|
+
export function timelineEvalReviewItems(timeline: Awaited<ReturnType<typeof evalTimeline>>, node: string): ReviewItem[] {
|
|
353
|
+
const declared = new Set(timeline.scenarios.map((scenario) => scenario.name))
|
|
354
|
+
const latest = new Map<string, any>()
|
|
355
|
+
for (const reading of timeline.readings) {
|
|
356
|
+
if (declared.has(reading.scenario) && !latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
357
|
+
}
|
|
358
|
+
return [
|
|
359
|
+
...timeline.scenarios.filter((scenario) => !latest.has(scenario.name)).map((scenario) => ({
|
|
293
360
|
...scenario,
|
|
294
361
|
scenario: scenario.name,
|
|
295
362
|
node,
|
|
296
363
|
filterKind: EVAL_FILTER_KIND.UNMEASURED,
|
|
297
364
|
})),
|
|
298
|
-
...
|
|
365
|
+
...[...latest.values()].map((reading) => ({
|
|
299
366
|
...reading,
|
|
300
367
|
state: evalReviewState(reading),
|
|
301
368
|
node,
|
|
302
369
|
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
303
|
-
filterKey: `${EVAL_FILTER_KIND.RESULT}:${
|
|
370
|
+
filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
|
|
304
371
|
})),
|
|
305
372
|
...(timeline.dangling ?? []).map((track) => ({
|
|
306
373
|
...track,
|
|
@@ -309,6 +376,13 @@ async function timelineEvalReview(text: string, requestedPage: unknown) {
|
|
|
309
376
|
filterKey: `${EVAL_FILTER_KIND.DANGLING}:${track.threadId}`,
|
|
310
377
|
})),
|
|
311
378
|
]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function timelineEvalReview(text: string, requestedPage: unknown) {
|
|
382
|
+
const node = readToken(text, 'node')
|
|
383
|
+
if (!node) return null
|
|
384
|
+
const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
|
|
385
|
+
const items = timelineEvalReviewItems(timeline, node)
|
|
312
386
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
313
387
|
return {
|
|
314
388
|
scope: null,
|
|
@@ -335,14 +409,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
335
409
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
336
410
|
return {
|
|
337
411
|
scope,
|
|
338
|
-
gates:
|
|
412
|
+
gates: [],
|
|
339
413
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
340
414
|
summary: model.summary,
|
|
341
415
|
evalRevision: model.evalRevision,
|
|
342
|
-
impact: model.impact,
|
|
343
416
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
344
|
-
domain: 'evals', scope,
|
|
345
|
-
evalRevision: model.evalRevision,
|
|
417
|
+
domain: 'evals', scope, query: text, gates: [], summary: model.summary,
|
|
418
|
+
evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
|
|
346
419
|
}),
|
|
347
420
|
}
|
|
348
421
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { streamSSE } from 'hono/streaming'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { harnessById } from './harness.js'
|
|
4
|
+
import { readAliasedRawRecord } from './layout.js'
|
|
5
|
+
import { currentHumanTurn } from './session-timeline.js'
|
|
6
|
+
import type { ExecutionStep, ExecutionTurn } from './execution-trace.js'
|
|
7
|
+
|
|
8
|
+
export type SessionExecution = Readonly<{
|
|
9
|
+
revision: string
|
|
10
|
+
turnId: string | null
|
|
11
|
+
workingNote: string | null
|
|
12
|
+
steps: readonly ExecutionStep[]
|
|
13
|
+
}>
|
|
14
|
+
|
|
15
|
+
const absentExecution = (turn: ExecutionTurn | null = null): SessionExecution => ({ revision: `turn:${turn?.token ?? '0'}`, turnId: turn?.token ?? null, workingNote: null, steps: [] })
|
|
16
|
+
|
|
17
|
+
// This is deliberately a read projection. Native transcript bytes stay within the adapter, while the API owns
|
|
18
|
+
// only enough information for the frontend's fixed renderer to paint the current execution slice.
|
|
19
|
+
export function readSessionExecution(id: string): SessionExecution | null {
|
|
20
|
+
let record: ReturnType<typeof readAliasedRawRecord>
|
|
21
|
+
try { record = readAliasedRawRecord(id) } catch { return null }
|
|
22
|
+
if (!record?.governed) return null
|
|
23
|
+
const turn = currentHumanTurn(id)
|
|
24
|
+
const threadId = typeof record.harness_session_id === 'string' ? record.harness_session_id : ''
|
|
25
|
+
if (!threadId) return absentExecution(turn)
|
|
26
|
+
try {
|
|
27
|
+
const trace = harnessById(typeof record.harness === 'string' && record.harness ? record.harness : 'claude')
|
|
28
|
+
.executionTrace(threadId, turn)
|
|
29
|
+
return trace || absentExecution(turn)
|
|
30
|
+
} catch {
|
|
31
|
+
// A missing/unreadable native source is an absent transient trace, not a synthetic conversation failure.
|
|
32
|
+
return absentExecution(turn)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TRACE_TICK_MS = 500
|
|
37
|
+
const HEARTBEAT_TICKS = 20
|
|
38
|
+
|
|
39
|
+
export async function sessionExecutionStream(c: Context) {
|
|
40
|
+
const id = c.req.param('id') || ''
|
|
41
|
+
if (!readSessionExecution(id)) return c.json({ error: 'no such session' }, 404)
|
|
42
|
+
return streamSSE(c, async (stream) => {
|
|
43
|
+
let aborted = false
|
|
44
|
+
let lastRevision = ''
|
|
45
|
+
let ticks = 0
|
|
46
|
+
const publish = async (): Promise<boolean> => {
|
|
47
|
+
const execution = readSessionExecution(id)
|
|
48
|
+
if (!execution) return false
|
|
49
|
+
if (execution.revision === lastRevision) return true
|
|
50
|
+
lastRevision = execution.revision
|
|
51
|
+
await stream.writeSSE({ event: 'execution', data: JSON.stringify(execution) })
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stream.onAbort(() => { aborted = true })
|
|
56
|
+
try {
|
|
57
|
+
if (!await publish()) return
|
|
58
|
+
while (!aborted) {
|
|
59
|
+
await stream.sleep(TRACE_TICK_MS)
|
|
60
|
+
if (aborted) break
|
|
61
|
+
if (!await publish()) break
|
|
62
|
+
if (++ticks % HEARTBEAT_TICKS === 0) await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// EventSource reconnects a dropped stream. A native trace read must never take down the session server.
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, isAbsolute, resolve } from 'node:path'
|
|
3
|
+
import { readRecordEntry, sessionArtifactPath } from './layout.js'
|
|
4
|
+
|
|
5
|
+
export class SessionFileError extends Error {
|
|
6
|
+
constructor(readonly status: 400 | 403 | 404 | 500, message: string) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'SessionFileError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SessionFileLock = <T>(id: string, body: () => T) => T
|
|
13
|
+
|
|
14
|
+
export const SESSION_FILE_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
|
|
15
|
+
export type SessionFilePreviewKind = 'text' | 'image'
|
|
16
|
+
const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.json', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.xml', '.py', '.go', '.rs', '.java', '.sh', '.sql'])
|
|
17
|
+
const IMAGE_TYPES: Record<string, string> = {
|
|
18
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const filesPath = (id: string) => sessionArtifactPath(id, 'files.json')
|
|
22
|
+
|
|
23
|
+
function requireSession(id: string): void {
|
|
24
|
+
const record = readRecordEntry(id)
|
|
25
|
+
if (record.kind === 'absent') throw new SessionFileError(404, `session ${id} does not exist`)
|
|
26
|
+
if (record.kind === 'corrupt') throw new SessionFileError(500, `session ${id} has an unreadable record: ${record.error}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readFiles(id: string): string[] {
|
|
30
|
+
const path = filesPath(id)
|
|
31
|
+
let parsed: unknown
|
|
32
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
|
|
33
|
+
catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
35
|
+
throw new SessionFileError(500, `session file list is unreadable: ${path} — ${error instanceof Error ? error.message : String(error)}`)
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== 'string' || !isAbsolute(value)))
|
|
38
|
+
throw new SessionFileError(500, `session file list is invalid: ${path}`)
|
|
39
|
+
return [...new Set(parsed)]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeFiles(id: string, files: string[]): void {
|
|
43
|
+
const path = filesPath(id)
|
|
44
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(tmp, `${JSON.stringify(files, null, 2)}\n`)
|
|
47
|
+
renameSync(tmp, path)
|
|
48
|
+
} finally {
|
|
49
|
+
try { unlinkSync(tmp) } catch { /* rename already consumed the temporary file */ }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function currentFile(path: string): void {
|
|
54
|
+
try {
|
|
55
|
+
if (!statSync(path).isFile()) throw new SessionFileError(400, `not a regular file: ${path}`)
|
|
56
|
+
accessSync(path, constants.R_OK)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof SessionFileError) throw error
|
|
59
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
60
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') throw new SessionFileError(404, `file does not exist: ${path}`)
|
|
61
|
+
throw new SessionFileError(403, `file is not readable: ${path}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function listSessionFiles(id: string): string[] {
|
|
66
|
+
requireSession(id)
|
|
67
|
+
return readSessionFiles(id)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Session projections already hold a parsed record; re-checking session.json here would make an in-memory
|
|
71
|
+
// projection depend on the public route's existence guard. The route-facing list keeps that guard above.
|
|
72
|
+
export function readSessionFiles(id: string): string[] {
|
|
73
|
+
return readFiles(id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function addSessionFile(id: string, input: string, lock: SessionFileLock, cwd = process.cwd()): { path: string; added: boolean } {
|
|
77
|
+
const path = resolve(cwd, input)
|
|
78
|
+
currentFile(path)
|
|
79
|
+
return lock(id, () => {
|
|
80
|
+
requireSession(id)
|
|
81
|
+
const files = readFiles(id)
|
|
82
|
+
if (files.includes(path)) return { path, added: false }
|
|
83
|
+
writeFiles(id, [...files, path])
|
|
84
|
+
return { path, added: true }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function retractSessionFile(id: string, input: string, lock: SessionFileLock, cwd = process.cwd()): { path: string; removed: boolean } {
|
|
89
|
+
const path = resolve(cwd, input)
|
|
90
|
+
return lock(id, () => {
|
|
91
|
+
requireSession(id)
|
|
92
|
+
const files = readFiles(id)
|
|
93
|
+
if (!files.includes(path)) return { path, removed: false }
|
|
94
|
+
writeFiles(id, files.filter((file) => file !== path))
|
|
95
|
+
return { path, removed: true }
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function openSessionFile(id: string, path: string): { path: string; name: string; size: number } {
|
|
100
|
+
requireSession(id)
|
|
101
|
+
if (!readFiles(id).includes(path)) throw new SessionFileError(403, 'that path was not posted by this session')
|
|
102
|
+
try {
|
|
103
|
+
const stat = statSync(path)
|
|
104
|
+
if (!stat.isFile()) throw new SessionFileError(404, `file no longer exists: ${path}`)
|
|
105
|
+
accessSync(path, constants.R_OK)
|
|
106
|
+
return { path, name: basename(path) || 'download', size: stat.size }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof SessionFileError) throw error
|
|
109
|
+
throw new SessionFileError(404, `file no longer exists: ${path}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function sessionFilePreviewKind(path: string): { kind: SessionFilePreviewKind; contentType: string } | null {
|
|
114
|
+
const extension = basename(path).toLowerCase().match(/\.[^.]+$/)?.[0]
|
|
115
|
+
if (!extension) return null
|
|
116
|
+
if (TEXT_EXTENSIONS.has(extension)) return { kind: 'text', contentType: 'text/plain; charset=utf-8' }
|
|
117
|
+
const contentType = IMAGE_TYPES[extension]
|
|
118
|
+
return contentType ? { kind: 'image', contentType } : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const sessionFilesPath = (id: string) => filesPath(id)
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { sessionStoreDir } from './layout.js'
|
|
3
3
|
import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
|
|
4
4
|
import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
|
|
5
|
-
import {
|
|
5
|
+
import { sessionTitle, type DisplayStatus, type Session } from './sessions.js'
|
|
6
6
|
|
|
7
7
|
// @@@ session-follow - supervision is FOLLOWING a log past a cursor, never polling a derived board. One tick
|
|
8
8
|
// costs ONE stat per target: if timeline.ndjson has not grown, nothing is opened and nothing is parsed. No
|
|
@@ -28,7 +28,7 @@ const trunc = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(
|
|
|
28
28
|
export function sessionEvent(s: Session): string {
|
|
29
29
|
const note = s.note ? ` — note: ${s.note}` : ''
|
|
30
30
|
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
31
|
-
return `[spex] ${s.status} · ${
|
|
31
|
+
return `[spex] ${s.status} · ${sessionTitle(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
|
|
32
32
|
}
|
|
33
33
|
// @@@ launchEvent - a session's FIRST sighting, emitted once per id whatever its status, so the stream is a
|
|
34
34
|
// complete lifecycle feed: launched → [transitions] → closed. A launch's own first line is `active` (not
|
|
@@ -36,7 +36,7 @@ export function sessionEvent(s: Session): string {
|
|
|
36
36
|
export function launchEvent(s: Session): string {
|
|
37
37
|
const note = s.note ? ` — note: ${s.note}` : ''
|
|
38
38
|
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
39
|
-
return `[spex] launched · ${
|
|
39
|
+
return `[spex] launched · ${sessionTitle(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
// `targets` is re-read every tick so a BROAD follow picks up sessions that launch while it runs; an explicit
|
|
@@ -131,7 +131,7 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
|
|
|
131
131
|
const e = slice.events[k]
|
|
132
132
|
if (e.kind === 'sent') {
|
|
133
133
|
const s = row?.(id, f.prev ?? 'unknown', null)
|
|
134
|
-
if (s) emit(`${tag}[spex] message · ${
|
|
134
|
+
if (s) emit(`${tag}[spex] message · ${sessionTitle(s)} — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${id}]`)
|
|
135
135
|
continue
|
|
136
136
|
}
|
|
137
137
|
const st = timelineDisplay(e)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { apiBaseInfo, reparentSessionRecords, type SessionReparentResult } from './sessions.js'
|
|
2
|
+
import { BackendError, backendConnectionRefused, clientReparent } from './client.js'
|
|
3
|
+
|
|
4
|
+
export class SessionReparentRequestError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'SessionReparentRequestError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseReparentRequest(body: unknown): { children: string[]; parent: string } {
|
|
12
|
+
if (!body || typeof body !== 'object') throw new SessionReparentRequestError('reparent body must be JSON')
|
|
13
|
+
const raw = body as { children?: unknown; parent?: unknown }
|
|
14
|
+
if (!Array.isArray(raw.children) || !raw.children.length || raw.children.some((id) => typeof id !== 'string' || !id.trim()))
|
|
15
|
+
throw new SessionReparentRequestError('reparent children must be one or more session ids')
|
|
16
|
+
if (typeof raw.parent !== 'string' || !raw.parent.trim()) throw new SessionReparentRequestError('reparent parent must be a session id')
|
|
17
|
+
return { children: raw.children.map((id) => id.trim()), parent: raw.parent.trim() }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function reparentRequest(body: unknown): Promise<SessionReparentResult> {
|
|
21
|
+
const request = parseReparentRequest(body)
|
|
22
|
+
return reparentSessionRecords(request.children, request.parent)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function reparentSessions(children: string[], parent: string): Promise<SessionReparentResult> {
|
|
26
|
+
try {
|
|
27
|
+
return await clientReparent(children, parent)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof BackendError) || (await apiBaseInfo()).source === 'flag' || !backendConnectionRefused(error)) throw error
|
|
30
|
+
console.error('spex: no backend reachable — reparenting in-process under the session record locks')
|
|
31
|
+
return reparentSessionRecords(children, parent)
|
|
32
|
+
}
|
|
33
|
+
}
|