spexcode 0.6.1 → 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 +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- 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 +584 -104
- package/spec-cli/src/uninstall.ts +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/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-F9uaAVcH.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-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.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-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.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-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/help.ts
CHANGED
|
@@ -19,7 +19,8 @@ none (or @all) means every session.`
|
|
|
19
19
|
|
|
20
20
|
const ROUTING_NOTE = `Backend routing: every backend-touching verb accepts --api <url> (--port <n> = localhost sugar) to name
|
|
21
21
|
its backend explicitly — the flag always wins. Bare, it resolves: worker env / the cwd project's live
|
|
22
|
-
recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING)
|
|
22
|
+
recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING). A password-gated explicit
|
|
23
|
+
gateway accepts --password <pw> (or SPEXCODE_PASSWORD); its self-signed certificate needs explicit --insecure.`
|
|
23
24
|
|
|
24
25
|
const DOT_NOTE = `\`.\` as a node argument means the node THIS worktree works on (the session's bound node, else the
|
|
25
26
|
node/<id> branch). One-shot payload reads (graph · spec search · session ls/show/review · eval ls ·
|
|
@@ -36,14 +37,21 @@ type SessionVerbHelp = readonly [usage: string | readonly string[], detail: stri
|
|
|
36
37
|
// Keeping the behavioral prose here prevents a compact probe from becoming a second, drifting manual.
|
|
37
38
|
function sessionHelpDefinitions(): Record<string, SessionVerbHelp> {
|
|
38
39
|
return {
|
|
39
|
-
new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>]',
|
|
40
|
+
new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>] [--name <name>] [--base <commit-ish>]',
|
|
40
41
|
`Launch a worker in its own node worktree. The materialized system contract reaches it
|
|
41
42
|
automatically; the prompt supplies the task context. Its first [[id]] mention binds the
|
|
42
43
|
session to that node. --prompt-file <path>|- carries a long prompt without shell quoting
|
|
43
|
-
(exclusive with the inline prompt).
|
|
44
|
+
(exclusive with the inline prompt). --name sets the session's initial display name without changing the prompt.
|
|
45
|
+
--base <commit-ish> pins the fork point instead of the source-of-truth branch's current head, so a run can be
|
|
46
|
+
reproduced against a frozen commit; a base that names no commit is refused before anything is created.
|
|
47
|
+
The successful receipt names what to read, monitor, and reply on.`],
|
|
44
48
|
ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
|
|
45
49
|
'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
|
|
46
50
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
51
|
+
files: [['spex session files add <path>', 'spex session files ls', 'spex session files retract <path>'],
|
|
52
|
+
'Publish, list, or withdraw YOUR session’s live file paths. Posting stores an absolute path beside the session record without copying bytes; the dashboard downloads it only when the human clicks.'],
|
|
53
|
+
web: [['spex session web add <url>', 'spex session web ls', 'spex session web retract <url>'],
|
|
54
|
+
'Publish, list, or withdraw YOUR session’s live loopback web-service URLs. Posting records no bytes and does not contact the service; the dashboard proxies the current HTTP/WebSocket service only when the human opens its preview.'],
|
|
47
55
|
watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
|
|
48
56
|
'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
|
|
49
57
|
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
@@ -58,8 +66,10 @@ It reads files only, so it needs no \`spex serve\` and costs the sessions it fol
|
|
|
58
66
|
session's store is gone.`, ['selector']],
|
|
59
67
|
review: ['spex session review <SEL> [--json]', 'Reports ahead · uncommitted · proposal · gates · merge-base diff.', ['selector']],
|
|
60
68
|
merge: ['spex session merge <SEL>', 'Dispatches a gated merge to the session\'s own agent; it does not close the session.', ['selector', 'project-bound']],
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
reparent: ['spex session reparent <child-SEL...> --to <parent-SEL>',
|
|
70
|
+
'Move one or more governed children to a replacement parent, replacing only the former parent\'s managed watch relation. It never restarts a child and works when the former parent is offline.', ['selector', 'project-bound']],
|
|
71
|
+
send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>', 'spex session send <SEL> --keys "<keys>"'],
|
|
72
|
+
`Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. Routing flags may precede or follow ordinary text; use -- before a message that begins with --. --keys is the LAST RESORT:
|
|
63
73
|
raw nav-mode keystrokes to a TUI dialog ("Up Up Enter", C-/M-/S- combos). The raw key surface
|
|
64
74
|
is UNSTABLE and can confirm dangerous dialogs — try a plain send first; use keys only when text
|
|
65
75
|
provably cannot land.`, ['selector', 'project-bound']],
|
|
@@ -88,13 +98,13 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
|
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
const SESSION_HELP_GROUPS = [
|
|
91
|
-
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
|
|
101
|
+
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge', 'reparent'] },
|
|
92
102
|
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'] },
|
|
93
|
-
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
|
|
103
|
+
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
|
|
94
104
|
{ title: 'Human escape hatch', verbs: ['attach'] },
|
|
95
105
|
] as const
|
|
96
106
|
|
|
97
|
-
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
|
|
107
|
+
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge/reparent) are PROJECT-BOUND: a backend serving
|
|
98
108
|
another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.`
|
|
99
109
|
|
|
100
110
|
function indent(text: string, spaces: number): string {
|
|
@@ -235,7 +245,7 @@ an admin password exists, and ungated projects serve open.`,
|
|
|
235
245
|
line: 'spec <verb> the governance graph: search · owner · lint · ack',
|
|
236
246
|
body: `Usage: spex spec search <query…> [--limit N=10] [--json]
|
|
237
247
|
spex spec owner <path> [--actionable]
|
|
238
|
-
spex spec lint
|
|
248
|
+
spex spec lint [--json]
|
|
239
249
|
spex spec ack <node-id>… --reason "<why the contract still holds>"
|
|
240
250
|
|
|
241
251
|
search — which spec node GOVERNS a topic, ranked by user-story relevance (which surfaces user-facing
|
|
@@ -259,7 +269,9 @@ leaf id reused) · mention (a [[id]] naming no node). Warns: coverage · drift
|
|
|
259
269
|
"ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
|
|
260
270
|
per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
|
|
261
271
|
confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the prepared reference hook; bypass SPEXCODE_SKIP_LINT=1);
|
|
262
|
-
contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
|
|
272
|
+
contrast \`spex eval lint\`, which is pure advisory and never blocks anyone. \`--json\` writes the
|
|
273
|
+
versioned machine report to stdout (source candidates + structured findings); its exit code still blocks
|
|
274
|
+
on errors.
|
|
263
275
|
|
|
264
276
|
ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
|
|
265
277
|
and the spec's contract still holds. --reason is required and recorded in the ack commit's body
|
|
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'
|
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
|
-
import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
|
|
6
|
+
import { defaultHarness, HARNESSES, harnessById, harnessByIdOrNull, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
|
|
7
7
|
import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
|
|
8
8
|
import { repoRoot } from './git.js'
|
|
9
9
|
import { endpointRecordPath } from './host.js'
|
|
@@ -219,6 +219,31 @@ const runtimePid = (file: string): number | null => {
|
|
|
219
219
|
catch { return null }
|
|
220
220
|
}
|
|
221
221
|
type SharedEntry = { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }
|
|
222
|
+
// A session record outlives the config that created it, so a removed plugin harness or a renamed harness id
|
|
223
|
+
// leaves a governed record this registry cannot resolve. That is a normal long-lived state for a host-wide
|
|
224
|
+
// sweep, not a request naming a bad id: the sweep reports the record and keeps every other owner's findings.
|
|
225
|
+
const unresolvableHarness = (rec: RawRecord): string | null => {
|
|
226
|
+
const id = rec.harness || defaultHarness.id
|
|
227
|
+
return harnessByIdOrNull(id) ? null : id
|
|
228
|
+
}
|
|
229
|
+
// Mirrors the corrupt-record owner: a record the sweep could not fully read stays a public owner so its
|
|
230
|
+
// absence from a process-derived group cannot read as health. Here the unknown half is the harness, so
|
|
231
|
+
// lifecycle is unknown and reclaim is closed rather than guessed from the record's own status field.
|
|
232
|
+
const unresolvedHarnessOwners = (recs: RawRecord[], budgets: ResourceBudgets, have: (sessionId: string) => boolean): ResourceOwner[] => {
|
|
233
|
+
const out: ResourceOwner[] = []
|
|
234
|
+
for (const rec of recs) {
|
|
235
|
+
const unresolved = unresolvableHarness(rec)
|
|
236
|
+
if (!unresolved || !rec.governed || rec.stopped || have(rec.session_id)) continue
|
|
237
|
+
out.push({
|
|
238
|
+
kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'unknown',
|
|
239
|
+
processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
|
|
240
|
+
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
241
|
+
findings: [`harness-unresolved:${unresolved}`],
|
|
242
|
+
reclaim: { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` },
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
222
247
|
const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<string, SharedEntry> => {
|
|
223
248
|
const out = new Map<string, { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }>()
|
|
224
249
|
for (const harness of HARNESSES) for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
|
|
@@ -229,7 +254,8 @@ const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<strin
|
|
|
229
254
|
// reported as an archive hazard. Clean archived records carry stopped:true and no loaded reference, so they
|
|
230
255
|
// contribute nothing to the active set while still preserving exact ownership if the invariant is violated.
|
|
231
256
|
if (!rec.governed) continue
|
|
232
|
-
const harness =
|
|
257
|
+
const harness = harnessByIdOrNull(rec.harness || defaultHarness.id)
|
|
258
|
+
if (!harness) continue // joined to no descriptor; surfaced as a finding on that record's own session owner
|
|
233
259
|
const exactKey = harness.targetDescriptorKey?.({ session: rec.session_id, harnessSessionId: rec.harness_session_id }) ?? null
|
|
234
260
|
for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
|
|
235
261
|
if (exactKey && descriptor.key !== exactKey) continue
|
|
@@ -381,6 +407,22 @@ const probeRuntime = async (descriptor: SharedRuntimeDescriptor): Promise<Shared
|
|
|
381
407
|
catch (error) { return { healthy: false, references: [], error: (error as Error).message } }
|
|
382
408
|
}
|
|
383
409
|
|
|
410
|
+
const sessionOwnerBlocker = (id: string, harnessId: string | null, recs: RawRecord[]): string | null => {
|
|
411
|
+
const targetRecord = recs.find((rec) => rec.session_id === id)
|
|
412
|
+
const targetHarness = harnessId ? harnessById(harnessId) : null
|
|
413
|
+
const targetThread = targetRecord?.harness_session_id
|
|
414
|
+
if (!targetHarness || !targetThread) return null
|
|
415
|
+
const ownerCount = recs.filter((rec) => rec.governed && (rec.harness || defaultHarness.id) === targetHarness.id && rec.harness_session_id === targetThread).length
|
|
416
|
+
if (ownerCount === 1) return null
|
|
417
|
+
const label = targetHarness.sharedRuntimes?.(runtimeRoot())[0]?.label ?? targetHarness.id
|
|
418
|
+
return `${label} target thread ${targetThread} has no one exact governed session owner`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function assertSessionOwnerSafe(id: string, harnessId: string): void {
|
|
422
|
+
const blocker = sessionOwnerBlocker(id, harnessId, rawRecords())
|
|
423
|
+
if (blocker) throw new ResourceConflict(blocker)
|
|
424
|
+
}
|
|
425
|
+
|
|
384
426
|
const sessionStopBlocker = async (
|
|
385
427
|
id: string,
|
|
386
428
|
harnessId: string | null,
|
|
@@ -390,6 +432,9 @@ const sessionStopBlocker = async (
|
|
|
390
432
|
): Promise<string | null> => {
|
|
391
433
|
const targetRecord = recs.find((rec) => rec.session_id === id)
|
|
392
434
|
const targetHarness = harnessId ? harnessById(harnessId) : null
|
|
435
|
+
const targetThread = targetRecord?.harness_session_id
|
|
436
|
+
const ownerBlocker = sessionOwnerBlocker(id, harnessId, recs)
|
|
437
|
+
if (ownerBlocker) return ownerBlocker
|
|
393
438
|
const exactKey = targetHarness?.targetDescriptorKey?.({ session: id, harnessSessionId: targetRecord?.harness_session_id }) ?? null
|
|
394
439
|
const targetDescriptors = targetHarness?.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
395
440
|
if (targetHarness && targetDescriptors.length === 0) return null
|
|
@@ -404,9 +449,9 @@ const sessionStopBlocker = async (
|
|
|
404
449
|
const pid = runtimePid(descriptor.pidFile)
|
|
405
450
|
const ownerCounts = new Map<string, number>()
|
|
406
451
|
for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
return `${descriptor.label} target thread ${
|
|
452
|
+
const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
|
|
453
|
+
if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
|
|
454
|
+
return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`
|
|
410
455
|
if (!knownProbes && descriptor.mutationGuard) {
|
|
411
456
|
if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
|
|
412
457
|
if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
|
|
@@ -506,6 +551,7 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
506
551
|
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
507
552
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
508
553
|
})
|
|
554
|
+
owners.push(...unresolvedHarnessOwners(recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
|
|
509
555
|
const report: ResourceReport = { version: 1, measuredAt: new Date().toISOString(), projectRoot: root, platform: platform(), available: false, unavailableReason: `host process metrics are unavailable on ${platform()}; shared runtime references remain visible`, host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null }, budgets, owners, totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 }, findings: owners.reduce((n, o) => n + o.findings.length, 0) }
|
|
510
556
|
if (opts.persist !== false) atomicJson(join(runtimeRoot(), 'resource-report.json'), report)
|
|
511
557
|
return report
|
|
@@ -562,8 +608,15 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
562
608
|
if (rec?.archived) findings.push('archived-runtime-hazard:leaf-still-resident')
|
|
563
609
|
if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
|
|
564
610
|
if (status !== 'active' && status !== 'queued' && totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
|
|
565
|
-
|
|
566
|
-
|
|
611
|
+
// The stop guard resolves the record's harness fail-loud, which is right for a stop REQUEST and wrong
|
|
612
|
+
// here: this sweep reads rows nobody named. An unresolvable harness makes stop safety unprovable, so it
|
|
613
|
+
// is reported and reclaim stays closed — never a resolvable-looking zero, never a lost report.
|
|
614
|
+
const unresolved = rec ? unresolvableHarness(rec) : null
|
|
615
|
+
if (unresolved) findings.push(`harness-unresolved:${unresolved}`)
|
|
616
|
+
const stopBlocker = rec && !unresolved ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
|
|
617
|
+
if (unresolved) {
|
|
618
|
+
reclaim = { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` }
|
|
619
|
+
} else if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
|
|
567
620
|
reclaim = { eligible: true, reason: `terminal lifecycle ${rec!.status}/${rec!.proposal}; a future exact action must revalidate every fact` }
|
|
568
621
|
} else reclaim = { eligible: false, reason: stopBlocker ? `shared runtime unsafe: ${stopBlocker}` : terminal(rec) ? 'terminal record is still consuming CPU; liveness contradicts safe retirement' : 'owner is not terminal; budget age/status alone never authorizes stop' }
|
|
569
622
|
} else if (owner.startsWith('shared:')) {
|
|
@@ -621,6 +674,8 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
621
674
|
findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
|
|
622
675
|
})
|
|
623
676
|
|
|
677
|
+
owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
|
|
678
|
+
|
|
624
679
|
// A referenced shared runtime with no readable process is still operationally important: keep its live or
|
|
625
680
|
// unknown refcount visible instead of silently omitting it from a process-derived report.
|
|
626
681
|
for (const [key, shared] of inv.shared) if (!owners.some((o) => o.kind === 'shared-runtime' && o.id === key)) {
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
// One browser-safe identity registry shared by backend validation and every dashboard projection.
|
|
2
2
|
// Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
|
|
3
|
+
//
|
|
4
|
+
// @@@ shape keys travel through two renderers - a shape's own attributes override the group's
|
|
5
|
+
// fill/stroke, which is how a multi-colour mark fits a single-fg registry. Both consumers pass the keys
|
|
6
|
+
// through verbatim: the string serializer spreads them as attributes, React spreads them as JSX props.
|
|
7
|
+
// So a key must be valid in BOTH, which rules out hyphenated names (React wants strokeWidth) - hence a
|
|
8
|
+
// painted seam outline rather than a stroked line, since per-shape stroke-width cannot be expressed.
|
|
3
9
|
|
|
4
10
|
export const DEFAULT_PROJECT_ICON = 'spexcode'
|
|
5
11
|
export const DEFAULT_GATEWAY_ICON = 'gateway'
|
|
6
12
|
|
|
7
13
|
export const IDENTITY_PRESETS = Object.freeze([
|
|
8
14
|
{
|
|
9
|
-
|
|
15
|
+
// The brand mark: two brackets holding one file, cleft corner to corner and regenerated on the
|
|
16
|
+
// upper-right of the cleft. Painted rather than stroked — see the paint note below. Geometry is
|
|
17
|
+
// docs/brand/mark-dark.svg scaled by 24/512; the two surface gradients flatten to their midpoints
|
|
18
|
+
// because a 24-unit chip has no room for a ramp and the format carries no defs.
|
|
19
|
+
id: 'spexcode', label: 'SpexCode', bg: '#12161C', fg: '#EFE8D8',
|
|
10
20
|
shapes: [
|
|
11
|
-
{ tag: '
|
|
12
|
-
{ tag: '
|
|
13
|
-
{ tag: '
|
|
14
|
-
{ tag: 'path', d: '
|
|
15
|
-
{ tag: 'path', d: '
|
|
21
|
+
{ tag: 'path', d: 'M 4.500,4.500 L 9.750,4.500 L 9.750,6.656 L 6.656,6.656 L 6.656,17.344 L 9.750,17.344 L 9.750,19.500 L 4.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
22
|
+
{ tag: 'path', d: 'M 19.500,4.500 L 14.250,4.500 L 14.250,6.656 L 17.344,6.656 L 17.344,17.344 L 14.250,17.344 L 14.250,19.500 L 19.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
23
|
+
{ tag: 'path', d: 'M 15.281,15.281 L 11.731,13.057 L 12.269,10.943 L 8.719,8.719 L 8.719,15.281 Z', fill: '#327D95', stroke: 'none' },
|
|
24
|
+
{ tag: 'path', d: 'M 8.719,8.719 L 12.269,10.943 L 11.731,13.057 L 15.281,15.281 L 15.281,12.000 L 12.000,8.719 Z', fill: '#82C3D6', stroke: 'none' },
|
|
25
|
+
{ tag: 'path', d: 'M 8.569,8.957 L 11.946,11.073 L 11.408,13.186 L 15.132,15.520 L 15.431,15.043 L 12.054,12.927 L 12.592,10.814 L 8.868,8.480 Z', fill: '#E4F7FD', stroke: 'none' },
|
|
16
26
|
],
|
|
17
27
|
},
|
|
18
28
|
{
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { serve } from '@hono/node-server'
|
|
2
2
|
import type { Server as HttpServer, ServerResponse as HttpServerResponse } from 'node:http'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { createReadStream } from 'node:fs'
|
|
5
|
+
import { Readable } from 'node:stream'
|
|
4
6
|
import { installConnectionReaper } from './reaper.js'
|
|
5
7
|
import { Hono } from 'hono'
|
|
6
8
|
import { cors } from 'hono/cors'
|
|
@@ -20,18 +22,21 @@ import { gitA, gitTry, repoRoot } from './git.js'
|
|
|
20
22
|
import { cockpitReview } from './cockpit.js'
|
|
21
23
|
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
22
24
|
import { readTimeline } from './session-timeline.js'
|
|
25
|
+
import { readSessionExecution, sessionExecutionStream } from './session-execution.js'
|
|
23
26
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
24
|
-
import {
|
|
27
|
+
import { readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
25
28
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
26
29
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
27
30
|
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
28
|
-
import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
31
|
+
import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
29
32
|
import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js'
|
|
33
|
+
import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js'
|
|
30
34
|
import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
31
35
|
import { installProcessGuards } from './resilience.js'
|
|
32
36
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
33
37
|
import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
|
|
34
38
|
import { collectResourceReport, ResourceConflict } from './host-resources.js'
|
|
39
|
+
import { reparentRequest, SessionReparentRequestError } from './session-reparent.js'
|
|
35
40
|
|
|
36
41
|
// last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
|
|
37
42
|
// self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
|
|
@@ -47,6 +52,7 @@ app.onError((error, c) => {
|
|
|
47
52
|
// stack would hide exactly the sentence the human needs.
|
|
48
53
|
if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
|
|
49
54
|
if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
|
|
55
|
+
if (error instanceof SessionReparentRequestError) return c.json({ error: error.message }, 400)
|
|
50
56
|
console.error(error)
|
|
51
57
|
return c.text('Internal Server Error', 500)
|
|
52
58
|
})
|
|
@@ -137,10 +143,7 @@ app.get('/api/edit', async (c) => {
|
|
|
137
143
|
}
|
|
138
144
|
return c.json({ patch })
|
|
139
145
|
})
|
|
140
|
-
//
|
|
141
|
-
// flag, newest-first; `hasEvalFile:false` when none declared. Contract belongs to [[spec-eval]].
|
|
142
|
-
app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
|
|
143
|
-
// the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts) — the REST pair of the GET above: a
|
|
146
|
+
// the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts): a
|
|
144
147
|
// programmatic caller files a reading (verdict + optional transcript) through the SAME append the CLI
|
|
145
148
|
// uses. The dashboard does not call this — [[event-detail]] reads readings and hosts remarks, never files.
|
|
146
149
|
app.post('/api/specs/:id/evals', async (c) => {
|
|
@@ -230,15 +233,27 @@ app.get('/api/evals', etag(), async (c) => {
|
|
|
230
233
|
const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
|
|
231
234
|
return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
|
|
232
235
|
})
|
|
236
|
+
// The exact impact graph proves a scope's membership and selector decisions. It is deliberately a named
|
|
237
|
+
// read: paged Evals rows retain their own reasons but never transport this scope-sized projection.
|
|
238
|
+
app.get('/api/evals/impact', etag(), async (c) => {
|
|
239
|
+
const scope = c.req.query('scope')?.trim()
|
|
240
|
+
if (!scope) return c.json({ error: 'scope is required' }, 400)
|
|
241
|
+
await ensureBoardFileWatchers(scope)
|
|
242
|
+
const model = await buildSessionEvals(scope)
|
|
243
|
+
return model
|
|
244
|
+
? c.json({ scope, impact: model.impact, evalRevision: model.evalRevision })
|
|
245
|
+
: c.json({ error: 'no such review source' }, 404)
|
|
246
|
+
})
|
|
233
247
|
// ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
|
|
234
|
-
// most five lightweight neighbors.
|
|
248
|
+
// most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
|
|
249
|
+
// serializes another scenario's history or the scoped model.
|
|
235
250
|
app.get('/api/evals/detail', etag(), async (c) => {
|
|
236
251
|
await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined)
|
|
237
252
|
const node = c.req.query('node')?.trim()
|
|
238
253
|
const scenario = c.req.query('scenario')?.trim()
|
|
239
254
|
if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
|
|
240
255
|
const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null)
|
|
241
|
-
return
|
|
256
|
+
return c.json(detail)
|
|
242
257
|
})
|
|
243
258
|
// the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
|
|
244
259
|
// resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
|
|
@@ -496,6 +511,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
496
511
|
if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
|
|
497
512
|
return c.text('capture failed', 502)
|
|
498
513
|
})
|
|
514
|
+
// A live adapter-owned execution observation, intentionally distinct from the durable conversation timeline.
|
|
515
|
+
// The response carries only the backend-normalized latest working note and typed tool rows; no transcript bytes
|
|
516
|
+
// or parser schema cross this API boundary.
|
|
517
|
+
app.get('/api/sessions/:id/execution', (c) => {
|
|
518
|
+
const execution = readSessionExecution(c.req.param('id') || '')
|
|
519
|
+
return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404)
|
|
520
|
+
})
|
|
521
|
+
app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c))
|
|
499
522
|
// the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
|
|
500
523
|
// FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
|
|
501
524
|
// the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
|
|
@@ -513,6 +536,39 @@ app.get('/api/sessions/:id', async (c) => {
|
|
|
513
536
|
if (!row) return c.json({ error: 'no such session' }, 404)
|
|
514
537
|
return c.json({ ...row, prompt: await sessionPrompt(id) })
|
|
515
538
|
})
|
|
539
|
+
app.get('/api/sessions/:id/files', (c) => {
|
|
540
|
+
try { return c.json({ files: listSessionFiles(c.req.param('id')) }) }
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
|
|
543
|
+
throw error
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
const sessionFileDownload = (c: any) => {
|
|
547
|
+
try {
|
|
548
|
+
const requested = c.req.query('path')
|
|
549
|
+
if (!requested) return c.json({ error: 'download needs a posted path' }, 400)
|
|
550
|
+
const file = openSessionFile(c.req.param('id'), requested)
|
|
551
|
+
const preview = c.req.query('preview') === '1'
|
|
552
|
+
const previewType = preview ? sessionFilePreviewKind(file.path) : null
|
|
553
|
+
if (preview && !previewType) return c.json({ error: 'no preview for this file type; download it instead' }, 415)
|
|
554
|
+
if (preview && file.size > SESSION_FILE_PREVIEW_MAX_BYTES)
|
|
555
|
+
return c.json({ error: `preview is limited to ${SESSION_FILE_PREVIEW_MAX_BYTES / (1024 * 1024)} MiB; download this ${file.size}-byte file instead` }, 413)
|
|
556
|
+
const headers: Record<string, string> = {
|
|
557
|
+
'Cache-Control': 'no-store',
|
|
558
|
+
'Content-Disposition': `${preview ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
559
|
+
'Content-Length': String(file.size),
|
|
560
|
+
'Content-Type': previewType?.contentType ?? 'application/octet-stream',
|
|
561
|
+
}
|
|
562
|
+
if (previewType) headers['X-Spexcode-Preview-Kind'] = previewType.kind
|
|
563
|
+
if (c.req.method === 'HEAD') return c.body(null, 200, headers)
|
|
564
|
+
return c.body(Readable.toWeb(createReadStream(file.path)) as ReadableStream, 200, headers)
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
|
|
567
|
+
throw error
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
app.get('/api/sessions/:id/files/download', sessionFileDownload)
|
|
571
|
+
app.on('HEAD', '/api/sessions/:id/files/download', sessionFileDownload)
|
|
516
572
|
// lifecycle transitions (thin callers of the session state machine)
|
|
517
573
|
// relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
|
|
518
574
|
// (409) when the agent is alive or its liveness is unproven — restore-on-alive was the incident's kill-shot.
|
|
@@ -523,11 +579,37 @@ app.post('/api/sessions/:id/resume', async (c) => {
|
|
|
523
579
|
const r = await resumeSession(c.req.param('id'), { force })
|
|
524
580
|
return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
|
|
525
581
|
})
|
|
526
|
-
//
|
|
527
|
-
//
|
|
582
|
+
// A reviewed dispatch to the session's own agent (it runs the merge), never a server merge. The caller returns
|
|
583
|
+
// the exact branch/base OIDs with a durable key; sessions.ts validates and accepts them once before ensure-live.
|
|
528
584
|
app.post('/api/sessions/:id/merge', async (c) => {
|
|
529
|
-
const
|
|
530
|
-
|
|
585
|
+
const requestKey = c.req.header('idempotency-key')
|
|
586
|
+
if (requestKey === undefined) {
|
|
587
|
+
return c.json({ dispatched: false, reason: 'Idempotency-Key is required with reviewed merge authority', code: 'session_merge_invalid_request' }, 400)
|
|
588
|
+
}
|
|
589
|
+
const rawBody = await c.req.text()
|
|
590
|
+
let body: unknown = {}
|
|
591
|
+
if (rawBody.trim()) {
|
|
592
|
+
try { body = JSON.parse(rawBody) }
|
|
593
|
+
catch { return c.json({ dispatched: false, reason: 'body must be valid JSON', code: 'session_merge_invalid_request' }, 400) }
|
|
594
|
+
}
|
|
595
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
596
|
+
return c.json({ dispatched: false, reason: 'body must be a JSON object', code: 'session_merge_invalid_request' }, 400)
|
|
597
|
+
}
|
|
598
|
+
const input = body as Record<string, unknown>
|
|
599
|
+
const keys = Object.keys(input)
|
|
600
|
+
const allowed = new Set(['expectedBranchHead', 'expectedBaseHead'])
|
|
601
|
+
if (keys.some((key) => !allowed.has(key))) {
|
|
602
|
+
return c.json({ dispatched: false, reason: `unknown session-merge field(s): ${keys.filter((key) => !allowed.has(key)).join(', ')}`, code: 'session_merge_invalid_request' }, 400)
|
|
603
|
+
}
|
|
604
|
+
if (typeof input.expectedBranchHead !== 'string' || typeof input.expectedBaseHead !== 'string') {
|
|
605
|
+
return c.json({ dispatched: false, reason: 'expectedBranchHead and expectedBaseHead must be strings', code: 'session_merge_invalid_request' }, 400)
|
|
606
|
+
}
|
|
607
|
+
const r = await mergeSession(c.req.param('id'), {
|
|
608
|
+
requestKey,
|
|
609
|
+
expectedBranchHead: input.expectedBranchHead,
|
|
610
|
+
expectedBaseHead: input.expectedBaseHead,
|
|
611
|
+
})
|
|
612
|
+
return c.json(r, r.dispatched ? 200 : (r.status ?? 409))
|
|
531
613
|
})
|
|
532
614
|
|
|
533
615
|
// one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
|
|
@@ -633,6 +715,11 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
633
715
|
}
|
|
634
716
|
return c.json({ error: 'input needs kind: "text" | "command" | "keys"' }, 400)
|
|
635
717
|
})
|
|
718
|
+
app.post('/api/sessions/reparent', async (c) => {
|
|
719
|
+
const result = await reparentRequest(await c.req.json().catch(() => null))
|
|
720
|
+
notifyBoardChanged('sessions')
|
|
721
|
+
return c.json(result)
|
|
722
|
+
})
|
|
636
723
|
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
637
724
|
// removes the worktree. {ok:false} = no such session.
|
|
638
725
|
app.post('/api/sessions/:id/stop', async (c) => {
|
|
@@ -646,7 +733,8 @@ app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
|
646
733
|
})
|
|
647
734
|
app.post('/api/sessions/:id/close', async (c) => {
|
|
648
735
|
const sessionId = c.req.param('id')
|
|
649
|
-
const
|
|
736
|
+
const body = await c.req.json().catch(() => ({}))
|
|
737
|
+
const ok = await closeSession(sessionId, body?.source)
|
|
650
738
|
// The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
|
|
651
739
|
// invalidation must happen before the success response rather than leaving the confirming board to patrol.
|
|
652
740
|
if (ok) notifyBoardChanged('sessions')
|