spexcode 0.5.7 → 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 +199 -60
- 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 +42 -30
- 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/client.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { platform } from 'node:os'
|
|
3
|
+
import { repoRoot } from './git.js'
|
|
4
|
+
import { resourceBudgets, type ResourceReport } from './host-resources.js'
|
|
5
|
+
import { listSessionIds, readPublicRecordEntry } from './layout.js'
|
|
6
|
+
import { cockpitReview, type CockpitReview } from './cockpit.js'
|
|
7
|
+
import { apiBase, apiBaseInfo, assertProjectMatch, fromRaw, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
5
8
|
|
|
6
9
|
export class BackendError extends Error {
|
|
7
10
|
constructor(message: string, readonly status?: number) {
|
|
8
11
|
super(message)
|
|
9
|
-
this.name = 'BackendError' //
|
|
12
|
+
this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
// the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
|
|
14
17
|
// is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
|
|
15
18
|
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
16
|
-
if (maintenanceBrokerDescriptors() && init?.method && !['GET', 'HEAD'].includes(init.method.toUpperCase()))
|
|
17
|
-
throw new BackendError('maintenance_capability_missing: the operator broker admits only its exact stop/resume plan')
|
|
18
19
|
const base = await apiBase()
|
|
19
20
|
try {
|
|
20
21
|
return await fetch(`${base}${path}`, init)
|
|
@@ -28,122 +29,108 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
|
28
29
|
const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
|
|
29
30
|
const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
30
31
|
const seg = (id: string) => encodeURIComponent(id)
|
|
31
|
-
type BrokerRequest = { op: string; sessionId?: string; force?: boolean }
|
|
32
|
-
type BrokerOutcome = 'committed' | 'refused' | 'indeterminate'
|
|
33
|
-
type BrokerResponse = { ok: boolean; outcome?: BrokerOutcome; status?: number; body?: any; code?: string; error?: string }
|
|
34
|
-
function brokerRequest(request: BrokerRequest): BrokerResponse | null {
|
|
35
|
-
const fds = maintenanceBrokerDescriptors()
|
|
36
|
-
if (!fds) return null
|
|
37
|
-
const startToken = processStartToken(process.pid)
|
|
38
|
-
if (!startToken) throw new BackendError(`maintenance broker cannot identify client PID ${process.pid}`)
|
|
39
|
-
const id = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
40
|
-
const frame = `${JSON.stringify({ v: 1, id, ...request, client: { pid: process.pid, startToken } })}\n`
|
|
41
|
-
if (Buffer.byteLength(frame) > 4_096) throw new BackendError('maintenance broker request exceeded PIPE_BUF')
|
|
42
|
-
const token = Buffer.alloc(1)
|
|
43
|
-
let ownsTurn = false
|
|
44
|
-
try {
|
|
45
|
-
if (readSync(fds.turn, token, 0, 1, null) !== 1 || token[0] !== 1)
|
|
46
|
-
throw new BackendError('maintenance broker turn token was unavailable or invalid')
|
|
47
|
-
ownsTurn = true
|
|
48
|
-
if (writeSync(fds.request, frame) !== Buffer.byteLength(frame)) throw new BackendError('maintenance broker request write was incomplete')
|
|
49
|
-
let line = ''
|
|
50
|
-
const byte = Buffer.alloc(1)
|
|
51
|
-
while (!line.endsWith('\n')) {
|
|
52
|
-
const count = readSync(fds.response, byte, 0, 1, null)
|
|
53
|
-
if (count === 0) throw new BackendError('maintenance broker closed before replying')
|
|
54
|
-
line += byte.toString('utf8', 0, count)
|
|
55
|
-
if (line.length > 64 * 1024) throw new BackendError('maintenance broker response exceeded its bound')
|
|
56
|
-
}
|
|
57
|
-
const response = JSON.parse(line) as BrokerResponse & { id?: string }
|
|
58
|
-
if (response.id !== id) throw new BackendError('maintenance broker response identity mismatch')
|
|
59
|
-
return response
|
|
60
|
-
} finally {
|
|
61
|
-
if (ownsTurn) {
|
|
62
|
-
try { writeSync(fds.turn, token) } catch { /* broker shutdown owns the failure */ }
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
const brokerMutation = (request: BrokerRequest): BrokerResponse | null => brokerRequest(request)
|
|
67
32
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return { status: r.status, lease }
|
|
33
|
+
// A rendezvous liveness probe can displace the real delivery client.
|
|
34
|
+
function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
|
|
35
|
+
if (!rec.worktreePath || !existsSync(rec.worktreePath)) return 'retired'
|
|
36
|
+
if (rec.archived) return 'offline'
|
|
37
|
+
if (rec.status === 'awaiting') return rec.proposal === 'merge' ? 'review' : rec.proposal === 'close' ? 'close-pending' : 'done'
|
|
38
|
+
return rec.status === 'active' || rec.status === 'idle' ? 'unknown' : rec.status
|
|
75
39
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
40
|
+
|
|
41
|
+
function corruptCachedSession(id: string, reason: string): Session {
|
|
42
|
+
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
43
|
+
return {
|
|
44
|
+
id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
|
|
45
|
+
parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
|
|
46
|
+
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
47
|
+
note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
|
|
48
|
+
prompt: null, promptPreview: null, created: 0, activity: null, sortKey: null,
|
|
49
|
+
}
|
|
80
50
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
export
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
51
|
+
|
|
52
|
+
// Also the board a FOLLOW resolves its selectors against ([[session-follow]]): following must work with no
|
|
53
|
+
// `spex serve` running, and asking a backend for the board would cost exactly the tmux/rendezvous probes the
|
|
54
|
+
// follow exists to eliminate. Every field here comes from the store — nothing is probed.
|
|
55
|
+
export function localCachedSessions(includeArchived = false): Session[] {
|
|
56
|
+
const rows: Session[] = []
|
|
57
|
+
for (const id of listSessionIds()) {
|
|
58
|
+
const entry = readPublicRecordEntry(id)
|
|
59
|
+
if (entry.kind === 'corrupt') {
|
|
60
|
+
rows.push(corruptCachedSession(id, entry.error))
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (entry.kind !== 'ok') continue
|
|
64
|
+
const rec = fromRaw(entry.raw)
|
|
65
|
+
if (!rec.governed) continue
|
|
66
|
+
rows.push(toSession(rec, cachedStatus(rec), 'unknown'))
|
|
67
|
+
}
|
|
68
|
+
return rows.filter((session) => includeArchived || !session.archived)
|
|
69
|
+
.sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
|
|
89
70
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
71
|
+
|
|
72
|
+
function localCachedResources(): ResourceReport {
|
|
73
|
+
const projectRoot = repoRoot()
|
|
74
|
+
const budgets = resourceBudgets(projectRoot)
|
|
75
|
+
const owners = localCachedSessions(true).map((session) => ({
|
|
76
|
+
kind: 'session' as const,
|
|
77
|
+
id: session.id,
|
|
78
|
+
label: session.label,
|
|
79
|
+
status: session.lifecycle,
|
|
80
|
+
liveness: 'unknown' as const,
|
|
81
|
+
proposal: session.proposal,
|
|
82
|
+
note: session.note,
|
|
83
|
+
worktreePath: session.path,
|
|
84
|
+
branch: session.branch,
|
|
85
|
+
archived: session.archived,
|
|
86
|
+
processes: [],
|
|
87
|
+
rssMiB: 0,
|
|
88
|
+
pssMiB: null,
|
|
89
|
+
cpuPercent: 0,
|
|
90
|
+
budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
|
|
91
|
+
findings: ['local-store-only: runtime liveness and process measurements are unavailable'],
|
|
92
|
+
reclaim: { eligible: false, reason: 'local store read cannot prove runtime ownership or liveness' },
|
|
93
|
+
}))
|
|
94
|
+
return {
|
|
95
|
+
version: 1,
|
|
96
|
+
measuredAt: new Date().toISOString(),
|
|
97
|
+
projectRoot,
|
|
98
|
+
platform: platform(),
|
|
99
|
+
available: false,
|
|
100
|
+
unavailableReason: 'backend unavailable; local session store has no runtime resource measurements',
|
|
101
|
+
host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null },
|
|
102
|
+
budgets,
|
|
103
|
+
owners,
|
|
104
|
+
totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 },
|
|
105
|
+
findings: owners.reduce((total, owner) => total + owner.findings.length, 0),
|
|
106
|
+
}
|
|
93
107
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
request: { op: 'stop' | 'resume'; sessionId: string; force?: boolean },
|
|
97
|
-
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
98
|
-
): Promise<BrokerResponse> {
|
|
99
|
-
const path = `/api/sessions/${seg(request.sessionId)}/${request.op}`
|
|
100
|
-
const controller = new AbortController()
|
|
101
|
-
const abort = () => controller.abort(options.signal?.reason)
|
|
102
|
-
if (options.signal?.aborted) abort()
|
|
103
|
-
else options.signal?.addEventListener('abort', abort, { once: true })
|
|
104
|
-
const timer = setTimeout(() => controller.abort(new Error('maintenance operation timed out')), options.timeoutMs ?? 5_000)
|
|
105
|
-
timer.unref()
|
|
108
|
+
|
|
109
|
+
async function cachedRead<T>(remote: () => Promise<T>, local: () => T | Promise<T>): Promise<T> {
|
|
106
110
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
try { body = await r.json() } catch {
|
|
113
|
-
return { ok: false, outcome: 'indeterminate', status: r.status, code: 'maintenance_response_malformed', error: 'maintenance operation returned malformed JSON' }
|
|
114
|
-
}
|
|
115
|
-
const committed = r.ok && body?.ok === true
|
|
116
|
-
const refused = (r.status >= 400 && r.status < 500 && (body?.ok === false || typeof body?.code === 'string'))
|
|
117
|
-
|| (r.ok && body?.ok === false && body?.refused === true)
|
|
118
|
-
const outcome: BrokerOutcome = committed ? 'committed' : refused ? 'refused' : 'indeterminate'
|
|
119
|
-
const code = typeof body?.code === 'string' ? body.code
|
|
120
|
-
: refused ? 'session_operation_refused'
|
|
121
|
-
: committed ? undefined : 'maintenance_operation_indeterminate'
|
|
122
|
-
return {
|
|
123
|
-
ok: committed,
|
|
124
|
-
outcome,
|
|
125
|
-
status: r.status,
|
|
126
|
-
body,
|
|
127
|
-
...(code ? { code } : {}),
|
|
128
|
-
...(!committed ? { error: body?.error || `status ${r.status}` } : {}),
|
|
129
|
-
}
|
|
130
|
-
} finally {
|
|
131
|
-
clearTimeout(timer)
|
|
132
|
-
options.signal?.removeEventListener('abort', abort)
|
|
111
|
+
return await remote()
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!(error instanceof BackendError) || error.status !== undefined || (await apiBaseInfo()).source === 'flag') throw error
|
|
114
|
+
console.error('spex session: backend unreachable; source: local session store (liveness unknown)')
|
|
115
|
+
return await local()
|
|
133
116
|
}
|
|
134
117
|
}
|
|
135
118
|
|
|
136
119
|
// GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
|
|
137
120
|
export async function clientListSessions(includeArchived = false): Promise<Session[]> {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
121
|
+
return cachedRead(async () => {
|
|
122
|
+
const r = await apiFetch(includeArchived ? '/api/sessions?all=1' : '/api/sessions')
|
|
123
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
|
|
124
|
+
return await r.json() as Session[]
|
|
125
|
+
}, () => localCachedSessions(includeArchived))
|
|
141
126
|
}
|
|
142
127
|
|
|
143
128
|
export async function clientResources(): Promise<import('./host-resources.js').ResourceReport> {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
129
|
+
return cachedRead(async () => {
|
|
130
|
+
const r = await apiFetch('/api/resources')
|
|
131
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} loading resources: ${await r.text()}`, r.status)
|
|
132
|
+
return await r.json() as import('./host-resources.js').ResourceReport
|
|
133
|
+
}, localCachedResources)
|
|
147
134
|
}
|
|
148
135
|
|
|
149
136
|
// resolve a selector (full id, id-prefix, node, or branch) against the live board, then call with the full id.
|
|
@@ -160,24 +147,24 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
|
|
|
160
147
|
return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
|
|
161
148
|
}
|
|
162
149
|
|
|
163
|
-
// POST /api/sessions/:id/input {kind:"text"}
|
|
164
|
-
//
|
|
150
|
+
// POST /api/sessions/:id/input {kind:"text"} appends the prompt to the durable timeline, then best-effort
|
|
151
|
+
// pokes the resolved adapter. HTTP failure means the append itself was refused.
|
|
165
152
|
export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
166
153
|
await guarded('session send')
|
|
167
|
-
|
|
168
|
-
if (brokered) return brokered.ok ? brokered.body as DispatchResult : { ok: false, error: brokered.code || brokered.error || 'maintenance_active' }
|
|
169
|
-
// `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
|
|
154
|
+
// `from` = the sending agent's own session id; the recipient's log records the sender ([[session-timeline]]) only when
|
|
170
155
|
// it's present (an agent send), so a human-shell send stays unrecorded.
|
|
171
156
|
const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
|
|
172
157
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
173
158
|
}
|
|
174
159
|
|
|
175
160
|
// GET /api/sessions/:id/review — the manager cockpit review bundle (null on 404).
|
|
176
|
-
export async function clientReview(id: string): Promise<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
161
|
+
export async function clientReview(id: string): Promise<CockpitReview | null> {
|
|
162
|
+
return cachedRead(async () => {
|
|
163
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/review`)
|
|
164
|
+
if (r.status === 404) return null
|
|
165
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} reviewing ${id}`, r.status)
|
|
166
|
+
return await r.json() as CockpitReview
|
|
167
|
+
}, () => cockpitReview(id))
|
|
181
168
|
}
|
|
182
169
|
|
|
183
170
|
// GET /api/sessions/:id/evals?format=html — the rendered EXPORT artifact ([[session-eval]]): the
|
|
@@ -237,11 +224,6 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
237
224
|
// carries a non-error advisory.
|
|
238
225
|
export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
239
226
|
await guarded('session resume')
|
|
240
|
-
const brokered = brokerMutation({ op: 'resume', sessionId: id, force })
|
|
241
|
-
if (brokered) {
|
|
242
|
-
if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
|
|
243
|
-
return brokered.body
|
|
244
|
-
}
|
|
245
227
|
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
246
228
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
247
229
|
}
|
|
@@ -250,11 +232,6 @@ export async function clientResume(id: string, force = false): Promise<{ ok: boo
|
|
|
250
232
|
// resumable). Distinct from close. {ok:false} = no such session.
|
|
251
233
|
export async function clientStop(id: string): Promise<boolean> {
|
|
252
234
|
await guarded('session stop')
|
|
253
|
-
const brokered = brokerMutation({ op: 'stop', sessionId: id })
|
|
254
|
-
if (brokered) {
|
|
255
|
-
if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
|
|
256
|
-
return !!brokered.body?.ok
|
|
257
|
-
}
|
|
258
235
|
const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
|
|
259
236
|
if (!r.ok) throw new BackendError(`backend refused to stop ${id}: ${await r.text()}`, r.status)
|
|
260
237
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
@@ -327,7 +304,12 @@ export async function clientSendRawKeys(id: string, keys: string[]): Promise<boo
|
|
|
327
304
|
// originating prompt. 404 → no such session.
|
|
328
305
|
export type ShowResult = { ok: true; session: Session & { prompt: string | null } } | { ok: false; status: number }
|
|
329
306
|
export async function clientShow(id: string): Promise<ShowResult> {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
307
|
+
return cachedRead(async () => {
|
|
308
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}`)
|
|
309
|
+
if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
|
|
310
|
+
return { ok: false, status: r.status }
|
|
311
|
+
}, () => {
|
|
312
|
+
const session = localCachedSessions(true).find((item) => item.id === id)
|
|
313
|
+
return session ? { ok: true, session } : { ok: false, status: 404 }
|
|
314
|
+
})
|
|
333
315
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { reviewPayload, type ReviewEvalGate, type ReviewPayload } from './sessions.js'
|
|
2
|
+
import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
|
|
3
|
+
|
|
4
|
+
// @@@ cockpit - where the manager's review answer is ASSEMBLED ([[manager-cockpit]]). It sits above both the
|
|
5
|
+
// session layer and the eval layer and may import either; that is what makes it the only honest home for a
|
|
6
|
+
// value composed from both. `sessions.ts` cannot be that home — the eval package imports it, so reaching the
|
|
7
|
+
// other way from there is a cycle, and the cycle used to be paid for with a deferred `await import()` whose
|
|
8
|
+
// comment explained why it had to be wrong. `reviews.ts` cannot be it either: that file is [[paged-review]]'s
|
|
9
|
+
// Issues/Evals paging server, and parking a session-cockpit concern under a node about paging would make that
|
|
10
|
+
// node's body false. It imports the eval package for its own reasons — the same direction, a different one.
|
|
11
|
+
|
|
12
|
+
// @@@ ONE composition, every entry point - `gates.evals` has exactly one producer, and it is this function.
|
|
13
|
+
// The cockpit review is reachable two ways: the HTTP route, and `clientReview`'s local answer when no backend
|
|
14
|
+
// resolves. If each composed its own gates, the same verb would report different shapes depending on whether a
|
|
15
|
+
// backend happened to be running — the very asymmetry the remote-client role split exists to remove — and a
|
|
16
|
+
// drift between the two readouts would be caught by no gate we have. So both call HERE.
|
|
17
|
+
export type CockpitReview = Omit<ReviewPayload, 'gates'> & {
|
|
18
|
+
gates: ReviewPayload['gates'] & { evals: ReviewEvalGate }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// @@@ evalGate - the measured-loss readout, taken from [[session-eval]]'s EXISTING projection. This is a cache
|
|
22
|
+
// READ and must stay one: `buildSessionEvals()` calls `reviewPayload()` itself, so BUILDING the model from here
|
|
23
|
+
// would recurse. That hazard used to be held off by a comment; now it is structural — `reviewPayload` no longer
|
|
24
|
+
// carries this field, so the eval side cannot re-enter through it. (It never read the field anyway: `gateRows`
|
|
25
|
+
// consumes lint/conflict/ahead/dirty only. The payload was carrying a value for a caller that ignored it.)
|
|
26
|
+
async function evalGate(id: string): Promise<ReviewEvalGate> {
|
|
27
|
+
const projection = sessionEvalProjection(id)
|
|
28
|
+
if (!projection) return { phase: 'unavailable' }
|
|
29
|
+
// only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
|
|
30
|
+
if (projection.phase !== 'ready' || !projection.value) {
|
|
31
|
+
return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
|
|
32
|
+
}
|
|
33
|
+
const summary = projection.value
|
|
34
|
+
return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// the cockpit's whole answer for one session: the session-side review plus the eval readout, composed once.
|
|
38
|
+
// The two run in parallel — the eval half is a projection read, independent of every git read in the payload.
|
|
39
|
+
export async function cockpitReview(id: string): Promise<CockpitReview | null> {
|
|
40
|
+
const [payload, evals] = await Promise.all([reviewPayload(id), evalGate(id)])
|
|
41
|
+
if (!payload) return null
|
|
42
|
+
return { ...payload, gates: { ...payload.gates, evals } }
|
|
43
|
+
}
|