spexcode 0.5.1 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
package/spec-cli/src/client.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
2
|
+
import { readSync, writeSync } from 'node:fs'
|
|
3
|
+
import { maintenanceBrokerDescriptors, type Capability, type LeaseOwner, type MaintenanceState } from './session-maintenance.js'
|
|
4
|
+
import { processStartToken } from './process-identity.js'
|
|
2
5
|
|
|
3
6
|
export class BackendError extends Error {
|
|
4
7
|
constructor(message: string, readonly status?: number) {
|
|
@@ -10,6 +13,8 @@ export class BackendError extends Error {
|
|
|
10
13
|
// the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
|
|
11
14
|
// is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
|
|
12
15
|
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')
|
|
13
18
|
const base = await apiBase()
|
|
14
19
|
try {
|
|
15
20
|
return await fetch(`${base}${path}`, init)
|
|
@@ -23,17 +28,127 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
|
23
28
|
const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
|
|
24
29
|
const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
25
30
|
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
|
+
|
|
68
|
+
export type MaintenanceLeaseResponse = { token: string; epoch: number; state: 'draining' | 'active'; owner: LeaseOwner; capabilities?: Capability[] }
|
|
69
|
+
export async function clientMaintenanceAcquire(capabilities: Capability[], ttlMs: number, waitMs: number): Promise<{ status: number; lease: MaintenanceLeaseResponse }> {
|
|
70
|
+
await guarded('session maintain')
|
|
71
|
+
const r = await apiFetch('/api/session-maintenance/acquire', post({ capabilities, ttlMs, waitMs }))
|
|
72
|
+
const lease = await r.json().catch(() => ({})) as MaintenanceLeaseResponse & { error?: string }
|
|
73
|
+
if (r.status !== 201 && r.status !== 202) throw new BackendError(`backend refused maintenance acquire (${r.status}): ${lease.error || 'invalid response'}`, r.status)
|
|
74
|
+
return { status: r.status, lease }
|
|
75
|
+
}
|
|
76
|
+
export async function clientMaintenanceStatus(): Promise<MaintenanceState> {
|
|
77
|
+
const r = await apiFetch('/api/session-maintenance')
|
|
78
|
+
if (!r.ok) throw new BackendError(`backend refused maintenance status (${r.status})`, r.status)
|
|
79
|
+
return await r.json() as MaintenanceState
|
|
80
|
+
}
|
|
81
|
+
const maintenanceHeaders = (token: string): Record<string, string> => ({
|
|
82
|
+
'content-type': 'application/json',
|
|
83
|
+
'x-spexcode-session-maintenance': token,
|
|
84
|
+
})
|
|
85
|
+
export async function clientMaintenanceHeartbeat(token: string, epoch: number, ttlMs: number): Promise<MaintenanceState> {
|
|
86
|
+
const r = await apiFetch('/api/session-maintenance/heartbeat', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch, ttlMs }) })
|
|
87
|
+
if (!r.ok) throw new BackendError(`backend refused maintenance heartbeat (${r.status}): ${await r.text()}`, r.status)
|
|
88
|
+
return await r.json() as MaintenanceState
|
|
89
|
+
}
|
|
90
|
+
export async function clientMaintenanceRelease(token: string, epoch: number): Promise<void> {
|
|
91
|
+
const r = await apiFetch('/api/session-maintenance/release', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch }) })
|
|
92
|
+
if (!r.ok) throw new BackendError(`backend refused maintenance release (${r.status}): ${await r.text()}`, r.status)
|
|
93
|
+
}
|
|
94
|
+
export async function clientMaintenanceOperation(
|
|
95
|
+
token: string,
|
|
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()
|
|
106
|
+
try {
|
|
107
|
+
const r = await apiFetch(path, {
|
|
108
|
+
method: 'POST', headers: maintenanceHeaders(token), signal: controller.signal,
|
|
109
|
+
body: JSON.stringify(request.op === 'resume' ? { force: request.force === true } : {}),
|
|
110
|
+
})
|
|
111
|
+
let body: any
|
|
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)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
26
135
|
|
|
27
136
|
// GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
|
|
28
|
-
export async function clientListSessions(): Promise<Session[]> {
|
|
29
|
-
const r = await apiFetch('/api/sessions')
|
|
137
|
+
export async function clientListSessions(includeArchived = false): Promise<Session[]> {
|
|
138
|
+
const r = await apiFetch(includeArchived ? '/api/sessions?all=1' : '/api/sessions')
|
|
30
139
|
if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
|
|
31
140
|
return await r.json() as Session[]
|
|
32
141
|
}
|
|
33
142
|
|
|
143
|
+
export async function clientResources(): Promise<import('./host-resources.js').ResourceReport> {
|
|
144
|
+
const r = await apiFetch('/api/resources')
|
|
145
|
+
if (!r.ok) throw new BackendError(`backend error ${r.status} loading resources: ${await r.text()}`, r.status)
|
|
146
|
+
return await r.json() as import('./host-resources.js').ResourceReport
|
|
147
|
+
}
|
|
148
|
+
|
|
34
149
|
// resolve a selector (full id, id-prefix, node, or branch) against the live board, then call with the full id.
|
|
35
|
-
export async function resolveClientSession(selector: string): Promise<Resolved> {
|
|
36
|
-
return resolveSession(selector, await clientListSessions())
|
|
150
|
+
export async function resolveClientSession(selector: string, includeArchived = true): Promise<Resolved> {
|
|
151
|
+
return resolveSession(selector, await clientListSessions(includeArchived))
|
|
37
152
|
}
|
|
38
153
|
|
|
39
154
|
// GET /api/sessions/:id/capture — the live pane as text. The discriminated result keeps the three failure
|
|
@@ -49,6 +164,8 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
|
|
|
49
164
|
// socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
|
|
50
165
|
export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
51
166
|
await guarded('session send')
|
|
167
|
+
const brokered = brokerMutation({ op: 'send', sessionId: id })
|
|
168
|
+
if (brokered) return brokered.ok ? brokered.body as DispatchResult : { ok: false, error: brokered.code || brokered.error || 'maintenance_active' }
|
|
52
169
|
// `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
|
|
53
170
|
// it's present (an agent send), so a human-shell send stays unrecorded.
|
|
54
171
|
const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
|
|
@@ -104,7 +221,7 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
|
|
|
104
221
|
}
|
|
105
222
|
if (!changed) return { ok: true, model: { ...first!, id, items } }
|
|
106
223
|
}
|
|
107
|
-
throw new BackendError(`session eval pages changed while
|
|
224
|
+
throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
|
|
108
225
|
}
|
|
109
226
|
|
|
110
227
|
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
|
|
@@ -120,6 +237,11 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
120
237
|
// carries a non-error advisory.
|
|
121
238
|
export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
122
239
|
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
|
+
}
|
|
123
245
|
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
124
246
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
125
247
|
}
|
|
@@ -128,7 +250,13 @@ export async function clientResume(id: string, force = false): Promise<{ ok: boo
|
|
|
128
250
|
// resumable). Distinct from close. {ok:false} = no such session.
|
|
129
251
|
export async function clientStop(id: string): Promise<boolean> {
|
|
130
252
|
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
|
+
}
|
|
131
258
|
const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
|
|
259
|
+
if (!r.ok) throw new BackendError(`backend refused to stop ${id}: ${await r.text()}`, r.status)
|
|
132
260
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
133
261
|
}
|
|
134
262
|
|
|
@@ -144,6 +272,16 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
|
|
|
144
272
|
export async function clientClose(id: string): Promise<boolean> {
|
|
145
273
|
await guarded('session close')
|
|
146
274
|
const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
|
|
275
|
+
if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
|
|
276
|
+
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// POST /api/sessions/:id/archive — cold-archive the session ([[archive]]). The legacy on=false spelling is a
|
|
280
|
+
// signpost to the same resume transition; it never performs a record-only unarchive.
|
|
281
|
+
export async function clientArchive(id: string, on = true): Promise<boolean> {
|
|
282
|
+
await guarded(on ? 'session archive' : 'session unarchive')
|
|
283
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/archive`, post({ on }))
|
|
284
|
+
if (!r.ok) throw new BackendError(`backend refused to ${on ? 'archive' : 'unarchive'} ${id}: ${await r.text()}`, r.status)
|
|
147
285
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
148
286
|
}
|
|
149
287
|
|
|
@@ -30,6 +30,7 @@ import { installConnectionReaper } from './reaper.js'
|
|
|
30
30
|
import { spexcodeHome, encodeProject } from './layout.js'
|
|
31
31
|
import { readEndpointRecord } from './host.js'
|
|
32
32
|
import { readGatewayIdentity, type ResolvedIdentity } from './project-identity.js'
|
|
33
|
+
import { proxyHttp } from './gateway.js'
|
|
33
34
|
|
|
34
35
|
export type HubProject = { id: string; identity: ResolvedIdentity; url: string; port: number; gated: boolean }
|
|
35
36
|
// @@@ extension seam - the hub stays the ONE routing+authorization server; a host-level caller
|
|
@@ -351,10 +352,5 @@ function proxyTo(req: http.IncomingMessage, res: http.ServerResponse, upstreamPo
|
|
|
351
352
|
const headers: http.OutgoingHttpHeaders = { ...req.headers }
|
|
352
353
|
const kept = stripGatewayCookies(req.headers.cookie)
|
|
353
354
|
if (kept) headers.cookie = kept; else delete headers.cookie
|
|
354
|
-
|
|
355
|
-
res.writeHead(upRes.statusCode || 502, upRes.headers)
|
|
356
|
-
upRes.pipe(res)
|
|
357
|
-
})
|
|
358
|
-
up.on('error', () => { if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('project backend unreachable') })
|
|
359
|
-
req.pipe(up)
|
|
355
|
+
proxyHttp(req, res, upstreamPort, path, headers)
|
|
360
356
|
}
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -227,19 +227,104 @@ const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.head
|
|
|
227
227
|
|
|
228
228
|
// reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
|
|
229
229
|
// stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
|
|
230
|
-
// `path`
|
|
231
|
-
//
|
|
232
|
-
export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
230
|
+
// `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
|
|
231
|
+
// prefix and gateway cookies); transport ownership stays here once. Defaults pass the request through.
|
|
232
|
+
export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string, headers: http.OutgoingHttpHeaders = req.headers) {
|
|
233
|
+
let upstreamResponse: http.IncomingMessage | null = null
|
|
234
|
+
let transform: ReturnType<typeof createGzip> | null = null
|
|
235
|
+
let settled = false
|
|
236
|
+
|
|
237
|
+
const removeDownstreamListeners = () => {
|
|
238
|
+
req.off('aborted', abortFromDownstream)
|
|
239
|
+
req.off('error', abortFromDownstream)
|
|
240
|
+
req.off('end', onRequestEnd)
|
|
241
|
+
req.off('close', onRequestClose)
|
|
242
|
+
res.off('error', abortFromDownstream)
|
|
243
|
+
res.off('close', onResponseClose)
|
|
244
|
+
res.off('finish', onResponseFinish)
|
|
245
|
+
}
|
|
246
|
+
const destroyUpstream = () => {
|
|
247
|
+
req.unpipe(up)
|
|
248
|
+
if (upstreamResponse && transform) {
|
|
249
|
+
upstreamResponse.unpipe(transform)
|
|
250
|
+
transform.unpipe(res)
|
|
251
|
+
transform.destroy()
|
|
252
|
+
} else if (upstreamResponse) {
|
|
253
|
+
upstreamResponse.unpipe(res)
|
|
254
|
+
}
|
|
255
|
+
upstreamResponse?.destroy()
|
|
256
|
+
up.destroy()
|
|
257
|
+
}
|
|
258
|
+
const settle = () => {
|
|
259
|
+
if (settled) return
|
|
260
|
+
settled = true
|
|
261
|
+
removeDownstreamListeners()
|
|
262
|
+
}
|
|
263
|
+
const onResponseFinish = () => {
|
|
264
|
+
if (settled) return
|
|
265
|
+
if (req.complete) { settle(); return }
|
|
266
|
+
// An upstream may reject a body before the client finishes sending it. The response is valid, but the
|
|
267
|
+
// upload leg is over: sever it upstream and drain the downstream body so the flushed response and
|
|
268
|
+
// keep-alive socket are not turned into a reset. Request abort ownership stays until end/close.
|
|
269
|
+
req.unpipe(up)
|
|
270
|
+
upstreamResponse?.destroy()
|
|
271
|
+
up.socket?.destroy()
|
|
272
|
+
up.destroy()
|
|
273
|
+
if (!req.destroyed) req.resume()
|
|
274
|
+
}
|
|
275
|
+
const abortFromDownstream = () => {
|
|
276
|
+
if (settled) return
|
|
277
|
+
settled = true
|
|
278
|
+
removeDownstreamListeners()
|
|
279
|
+
destroyUpstream()
|
|
280
|
+
}
|
|
281
|
+
const onRequestClose = () => {
|
|
282
|
+
if (!req.complete) abortFromDownstream()
|
|
283
|
+
else if (res.writableFinished) settle()
|
|
284
|
+
}
|
|
285
|
+
const onRequestEnd = () => { if (res.writableFinished) settle() }
|
|
286
|
+
const onResponseClose = () => {
|
|
287
|
+
if (!res.writableFinished) abortFromDownstream()
|
|
288
|
+
}
|
|
289
|
+
const failFromUpstream = () => {
|
|
290
|
+
if (settled) return
|
|
291
|
+
settled = true
|
|
292
|
+
removeDownstreamListeners()
|
|
293
|
+
destroyUpstream()
|
|
294
|
+
if (res.destroyed) return
|
|
295
|
+
if (!res.headersSent) { res.writeHead(502); res.end('upstream unreachable') }
|
|
296
|
+
else res.destroy()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: path ?? req.url, method: req.method, headers }, (received) => {
|
|
300
|
+
if (settled || res.destroyed) { received.destroy(); up.destroy(); return }
|
|
301
|
+
upstreamResponse = received
|
|
302
|
+
received.once('aborted', failFromUpstream)
|
|
303
|
+
received.once('error', failFromUpstream)
|
|
304
|
+
received.once('close', () => { if (!received.complete) failFromUpstream() })
|
|
305
|
+
|
|
306
|
+
const type = String(received.headers['content-type'] || '')
|
|
307
|
+
const skip = !wantsGzip(req) || received.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
|
|
308
|
+
if (skip) {
|
|
309
|
+
res.writeHead(received.statusCode || 502, received.headers)
|
|
310
|
+
received.pipe(res)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const headers = { ...received.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
|
|
238
314
|
delete headers['content-length'] // streamed; the encoded length isn't knowable up front
|
|
239
|
-
res.writeHead(
|
|
240
|
-
|
|
315
|
+
res.writeHead(received.statusCode || 502, headers)
|
|
316
|
+
transform = createGzip()
|
|
317
|
+
transform.once('error', failFromUpstream)
|
|
318
|
+
received.pipe(transform).pipe(res)
|
|
241
319
|
})
|
|
242
|
-
up.
|
|
320
|
+
up.once('error', failFromUpstream)
|
|
321
|
+
req.once('aborted', abortFromDownstream)
|
|
322
|
+
req.once('error', abortFromDownstream)
|
|
323
|
+
req.once('end', onRequestEnd)
|
|
324
|
+
req.once('close', onRequestClose)
|
|
325
|
+
res.once('error', abortFromDownstream)
|
|
326
|
+
res.once('close', onResponseClose)
|
|
327
|
+
res.once('finish', onResponseFinish)
|
|
243
328
|
req.pipe(up)
|
|
244
329
|
}
|
|
245
330
|
|