spexcode 0.5.2 → 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 +13 -52
- package/spec-cli/src/cli.ts +209 -71
- 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 +946 -71
- 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/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 +4 -8
- package/spec-cli/src/pi-headless.ts +17 -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 +6 -3
- 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 +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- 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-CadAKuSy.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-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { once } from 'node:events'
|
|
3
|
+
import type { Duplex, Readable, Writable } from 'node:stream'
|
|
4
|
+
import type { Capability, LeaseOwner, MaintenanceState } from './session-maintenance.js'
|
|
5
|
+
import { processStartToken } from './process-identity.js'
|
|
6
|
+
import {
|
|
7
|
+
clientMaintenanceAcquire,
|
|
8
|
+
clientMaintenanceHeartbeat,
|
|
9
|
+
clientMaintenanceOperation,
|
|
10
|
+
clientMaintenanceRelease,
|
|
11
|
+
clientMaintenanceStatus,
|
|
12
|
+
} from './client.js'
|
|
13
|
+
|
|
14
|
+
type BrokerRequest = {
|
|
15
|
+
v?: number
|
|
16
|
+
id?: string
|
|
17
|
+
op?: string
|
|
18
|
+
sessionId?: string
|
|
19
|
+
force?: boolean
|
|
20
|
+
client?: { pid?: number; startToken?: string }
|
|
21
|
+
}
|
|
22
|
+
type PlannedCapability = {
|
|
23
|
+
capability: Capability
|
|
24
|
+
state: 'unused' | 'inflight' | 'committed' | 'indeterminate'
|
|
25
|
+
requestId?: string
|
|
26
|
+
}
|
|
27
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
28
|
+
const capabilityKey = (capability: Capability): string => JSON.stringify(capability)
|
|
29
|
+
const capabilityList = (rows: unknown): Capability[] => Array.isArray(rows)
|
|
30
|
+
? rows.map((row: any) => row?.capability ?? row)
|
|
31
|
+
: []
|
|
32
|
+
const sameCapabilities = (left: readonly Capability[], right: readonly Capability[]): boolean =>
|
|
33
|
+
JSON.stringify(left) === JSON.stringify(right)
|
|
34
|
+
const sameOwner = (left: LeaseOwner | null | undefined, right: LeaseOwner): boolean =>
|
|
35
|
+
!!left && left.instanceId === right.instanceId && left.pid === right.pid && left.startToken === right.startToken
|
|
36
|
+
const assertLeaseView = (view: Pick<MaintenanceState, 'state' | 'epoch' | 'owner' | 'capabilities'>, epoch: number, owner: LeaseOwner, capabilities: readonly Capability[], allowDraining = false) => {
|
|
37
|
+
if (view.epoch !== epoch || (!allowDraining && view.state !== 'active') || (allowDraining && view.state !== 'active' && view.state !== 'draining'))
|
|
38
|
+
throw new Error('maintenance lease changed epoch or state')
|
|
39
|
+
if (!sameOwner(view.owner, owner)) throw new Error('maintenance lease owner generation changed')
|
|
40
|
+
if (!sameCapabilities(capabilityList(view.capabilities), capabilities)) throw new Error('maintenance lease changed its capability plan')
|
|
41
|
+
}
|
|
42
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
43
|
+
const reapCommandGroup = async (child: ReturnType<typeof spawn>, closed: Promise<[number | null, NodeJS.Signals | null]>) => {
|
|
44
|
+
if (!child.pid) return
|
|
45
|
+
const groupAlive = () => {
|
|
46
|
+
try { process.kill(-child.pid!, 0); return true } catch { return false }
|
|
47
|
+
}
|
|
48
|
+
if (groupAlive()) {
|
|
49
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
|
|
50
|
+
const deadline = Date.now() + 1_000
|
|
51
|
+
while (groupAlive() && Date.now() < deadline) await wait(20)
|
|
52
|
+
if (groupAlive()) {
|
|
53
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch { /* already exited */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const closedAfterReap = await new Promise<boolean>((resolve) => {
|
|
57
|
+
let settled = false
|
|
58
|
+
const finish = (value: boolean) => {
|
|
59
|
+
if (settled) return
|
|
60
|
+
settled = true
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
resolve(value)
|
|
63
|
+
}
|
|
64
|
+
const timer = setTimeout(() => finish(false), 2_000)
|
|
65
|
+
timer.unref()
|
|
66
|
+
void closed.then(() => finish(true))
|
|
67
|
+
})
|
|
68
|
+
if (!closedAfterReap) throw new Error('maintenance operator pipes did not close after exact process-group reap')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runMaintenanceWrapper(input: {
|
|
72
|
+
capabilities: Capability[]
|
|
73
|
+
ttlMs: number
|
|
74
|
+
waitMs: number
|
|
75
|
+
command: string[]
|
|
76
|
+
}): Promise<number> {
|
|
77
|
+
const acquired = await clientMaintenanceAcquire(input.capabilities, input.ttlMs, input.waitMs)
|
|
78
|
+
const { token, epoch, owner } = acquired.lease
|
|
79
|
+
const releaseable = typeof token === 'string' && /^[0-9a-f]{64}$/i.test(token) && Number.isSafeInteger(epoch)
|
|
80
|
+
let released = false
|
|
81
|
+
let authorityLost = false
|
|
82
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
83
|
+
let child: ReturnType<typeof spawn> | null = null
|
|
84
|
+
const pendingOperations = new Set<AbortController>()
|
|
85
|
+
let closeAdmission = () => {}
|
|
86
|
+
try {
|
|
87
|
+
if (!releaseable) throw new Error('maintenance acquire returned malformed private authority')
|
|
88
|
+
if (!owner || typeof owner.instanceId !== 'string' || !Number.isInteger(owner.pid) || typeof owner.startToken !== 'string')
|
|
89
|
+
throw new Error('maintenance acquire returned malformed supervisor authority')
|
|
90
|
+
if (!sameCapabilities(capabilityList(acquired.lease.capabilities), input.capabilities)) throw new Error('maintenance acquire returned a different capability plan')
|
|
91
|
+
|
|
92
|
+
let state = acquired.lease.state
|
|
93
|
+
const activationDeadline = Date.now() + input.ttlMs
|
|
94
|
+
try {
|
|
95
|
+
while (state === 'draining') {
|
|
96
|
+
const current = await clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
|
|
97
|
+
assertLeaseView(current, epoch, owner, input.capabilities, true)
|
|
98
|
+
const status = await clientMaintenanceStatus()
|
|
99
|
+
assertLeaseView(status, epoch, owner, input.capabilities, true)
|
|
100
|
+
state = status.state as 'draining' | 'active'
|
|
101
|
+
if (state !== 'active') {
|
|
102
|
+
if (Date.now() >= activationDeadline) throw new Error('maintenance acquisition did not become active before its ttl')
|
|
103
|
+
await sleep(50)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (state !== 'active') throw new Error(`maintenance acquisition entered unexpected state ${state}`)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
authorityLost = true
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
if (!input.command.length) throw new Error('maintenance wrapper needs a command after --')
|
|
112
|
+
|
|
113
|
+
const plan: PlannedCapability[] = input.capabilities.map((capability) => ({ capability: { ...capability }, state: 'unused' }))
|
|
114
|
+
let heartbeatFailure: Error | null = null
|
|
115
|
+
let heartbeatBusy = false
|
|
116
|
+
let failAuthority!: (error: Error) => void
|
|
117
|
+
const authorityFailure = new Promise<never>((_, reject) => {
|
|
118
|
+
failAuthority = (error) => {
|
|
119
|
+
if (heartbeatFailure) return
|
|
120
|
+
heartbeatFailure = error
|
|
121
|
+
reject(error)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
heartbeat = setInterval(() => {
|
|
125
|
+
if (heartbeatBusy || heartbeatFailure) return
|
|
126
|
+
heartbeatBusy = true
|
|
127
|
+
void clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
|
|
128
|
+
.then((current) => assertLeaseView(current, epoch, owner, input.capabilities))
|
|
129
|
+
.catch((error) => failAuthority(error instanceof Error ? error : new Error(String(error))))
|
|
130
|
+
.finally(() => { heartbeatBusy = false })
|
|
131
|
+
}, Math.max(250, Math.min(1_000, Math.floor(input.ttlMs / 3))))
|
|
132
|
+
heartbeat.unref()
|
|
133
|
+
|
|
134
|
+
child = spawn(input.command[0], input.command.slice(1), {
|
|
135
|
+
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe', 'pipe'],
|
|
136
|
+
env: { ...process.env, SPEXCODE_MAINTENANCE_BROKER_FDS: '3,4,5' },
|
|
137
|
+
detached: true,
|
|
138
|
+
})
|
|
139
|
+
const exited = once(child, 'exit') as Promise<[number | null, NodeJS.Signals | null]>
|
|
140
|
+
const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>
|
|
141
|
+
const childStdio = child.stdio as Array<Readable | Writable | Duplex | null | undefined>
|
|
142
|
+
const requestPipe = childStdio[3] as Readable
|
|
143
|
+
const responsePipe = childStdio[4] as Writable
|
|
144
|
+
const turnPipe = childStdio[5] as Duplex
|
|
145
|
+
requestPipe.setEncoding('utf8')
|
|
146
|
+
let pending = ''
|
|
147
|
+
let serial = Promise.resolve()
|
|
148
|
+
let brokerWork = 0
|
|
149
|
+
let brokerOpen = true
|
|
150
|
+
let transportLossTimer: ReturnType<typeof setTimeout> | null = null
|
|
151
|
+
const brokerTransportLost = (error: unknown) => {
|
|
152
|
+
if (!brokerOpen || transportLossTimer) return
|
|
153
|
+
transportLossTimer = setTimeout(() => {
|
|
154
|
+
transportLossTimer = null
|
|
155
|
+
if (brokerOpen && child?.exitCode === null && child.signalCode === null) {
|
|
156
|
+
failAuthority(error instanceof Error ? error : new Error('maintenance broker transport closed while the operator command was live'))
|
|
157
|
+
}
|
|
158
|
+
}, 50)
|
|
159
|
+
transportLossTimer.unref()
|
|
160
|
+
}
|
|
161
|
+
requestPipe.on('error', brokerTransportLost)
|
|
162
|
+
requestPipe.on('end', () => brokerTransportLost(new Error('maintenance broker request pipe closed while the operator command was live')))
|
|
163
|
+
responsePipe.on('error', brokerTransportLost)
|
|
164
|
+
responsePipe.on('close', () => brokerTransportLost(new Error('maintenance broker response pipe closed while the operator command was live')))
|
|
165
|
+
turnPipe.on('error', brokerTransportLost)
|
|
166
|
+
turnPipe.on('end', () => brokerTransportLost(new Error('maintenance broker turn pipe closed while the operator command was live')))
|
|
167
|
+
closeAdmission = () => {
|
|
168
|
+
if (!brokerOpen) return
|
|
169
|
+
brokerOpen = false
|
|
170
|
+
for (const controller of pendingOperations) controller.abort(new Error('maintenance broker admission closed'))
|
|
171
|
+
if (transportLossTimer) clearTimeout(transportLossTimer)
|
|
172
|
+
transportLossTimer = null
|
|
173
|
+
try { requestPipe.destroy() } catch { /* already closed */ }
|
|
174
|
+
try { turnPipe.destroy() } catch { /* already closed */ }
|
|
175
|
+
}
|
|
176
|
+
turnPipe.on('data', (chunk: Buffer) => {
|
|
177
|
+
if (!brokerOpen) return
|
|
178
|
+
for (const byte of chunk) {
|
|
179
|
+
if (byte !== 1) return failAuthority(new Error('maintenance broker turn token was corrupted'))
|
|
180
|
+
turnPipe.write(Buffer.from([1]))
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
turnPipe.write(Buffer.from([1]))
|
|
184
|
+
const reply = (id: string | undefined, body: Record<string, unknown>) => {
|
|
185
|
+
if (brokerOpen) responsePipe.write(`${JSON.stringify({ id, ...body })}\n`)
|
|
186
|
+
}
|
|
187
|
+
const handle = async (request: BrokerRequest) => {
|
|
188
|
+
if (!brokerOpen) return
|
|
189
|
+
if (request.v !== 1 || typeof request.id !== 'string' || typeof request.op !== 'string' || typeof request.sessionId !== 'string'
|
|
190
|
+
|| !Number.isInteger(request.client?.pid) || typeof request.client?.startToken !== 'string'
|
|
191
|
+
|| processStartToken(request.client.pid!) !== request.client.startToken) {
|
|
192
|
+
reply(request.id, { ok: false, code: 'maintenance_invalid', error: 'malformed broker request' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const candidate: Capability | null = request.op === 'stop'
|
|
196
|
+
? { op: 'stop', sessionId: request.sessionId }
|
|
197
|
+
: request.op === 'resume' && typeof request.force === 'boolean'
|
|
198
|
+
? { op: 'resume', sessionId: request.sessionId, force: request.force }
|
|
199
|
+
: null
|
|
200
|
+
const matching = candidate && plan.find((entry) => capabilityKey(entry.capability) === capabilityKey(candidate))
|
|
201
|
+
const planned = matching?.state === 'unused' ? matching : null
|
|
202
|
+
if (!planned) {
|
|
203
|
+
reply(request.id, {
|
|
204
|
+
ok: false,
|
|
205
|
+
code: matching ? 'maintenance_capability_used' : 'maintenance_capability_missing',
|
|
206
|
+
error: matching ? 'operation is already inflight, committed, or indeterminate' : 'operation is not in the exact maintenance plan',
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
planned.state = 'inflight'
|
|
211
|
+
planned.requestId = request.id
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
pendingOperations.add(controller)
|
|
214
|
+
try {
|
|
215
|
+
const result = await clientMaintenanceOperation(token, candidate!, { signal: controller.signal, timeoutMs: Math.min(5_000, input.ttlMs) })
|
|
216
|
+
planned.state = result.outcome === 'committed' ? 'committed' : result.outcome === 'refused' ? 'unused' : 'indeterminate'
|
|
217
|
+
delete planned.requestId
|
|
218
|
+
reply(request.id, result)
|
|
219
|
+
} catch (error) {
|
|
220
|
+
planned.state = 'indeterminate'
|
|
221
|
+
delete planned.requestId
|
|
222
|
+
reply(request.id, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) })
|
|
223
|
+
} finally {
|
|
224
|
+
pendingOperations.delete(controller)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
requestPipe.on('data', (chunk: Buffer | string) => {
|
|
228
|
+
pending += chunk.toString()
|
|
229
|
+
for (;;) {
|
|
230
|
+
const newline = pending.indexOf('\n')
|
|
231
|
+
if (newline < 0) break
|
|
232
|
+
const line = pending.slice(0, newline); pending = pending.slice(newline + 1)
|
|
233
|
+
if (!line) continue
|
|
234
|
+
brokerWork++
|
|
235
|
+
serial = serial.then(async () => {
|
|
236
|
+
try { await handle(JSON.parse(line) as BrokerRequest) }
|
|
237
|
+
catch (error) { reply(undefined, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) }) }
|
|
238
|
+
}).finally(() => { brokerWork-- })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const outcome = await Promise.race([
|
|
242
|
+
exited.then(([code, signal]) => ({ kind: 'exit' as const, code, signal })),
|
|
243
|
+
authorityFailure.catch((error) => ({ kind: 'lost' as const, error: error instanceof Error ? error : new Error(String(error)) })),
|
|
244
|
+
])
|
|
245
|
+
if (outcome.kind === 'lost') {
|
|
246
|
+
authorityLost = true
|
|
247
|
+
closeAdmission()
|
|
248
|
+
await reapCommandGroup(child, closed)
|
|
249
|
+
throw outcome.error
|
|
250
|
+
}
|
|
251
|
+
const brokerIndeterminate = brokerWork > 0 || pendingOperations.size > 0 || pending.trim().length > 0
|
|
252
|
+
if (brokerIndeterminate) authorityLost = true
|
|
253
|
+
closeAdmission()
|
|
254
|
+
await serial
|
|
255
|
+
try { await reapCommandGroup(child, closed) }
|
|
256
|
+
catch (error) { authorityLost = true; throw error }
|
|
257
|
+
if (brokerIndeterminate) {
|
|
258
|
+
throw new Error('operator exited while a maintenance broker operation was indeterminate')
|
|
259
|
+
}
|
|
260
|
+
if (heartbeatFailure) {
|
|
261
|
+
authorityLost = true
|
|
262
|
+
throw heartbeatFailure
|
|
263
|
+
}
|
|
264
|
+
return outcome.code ?? (outcome.signal ? 1 : 0)
|
|
265
|
+
} finally {
|
|
266
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
267
|
+
closeAdmission()
|
|
268
|
+
try { child?.stdio[3]?.destroy() } catch { /* child already closed */ }
|
|
269
|
+
try { child?.stdio[4]?.destroy() } catch { /* child already closed */ }
|
|
270
|
+
try { (child?.stdio as Array<Readable | Writable | null | undefined> | undefined)?.[5]?.destroy() } catch { /* child already closed */ }
|
|
271
|
+
if (releaseable && !released && !authorityLost) {
|
|
272
|
+
await clientMaintenanceRelease(token, epoch)
|
|
273
|
+
released = true
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -23,7 +23,7 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
|
|
|
23
23
|
// a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
|
|
24
24
|
// cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
|
|
25
25
|
// harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
|
|
26
|
-
//
|
|
26
|
+
// surface:system plugin bodies (in name order) — written WHOLE into each
|
|
27
27
|
// harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
|
|
28
28
|
// (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
|
|
29
29
|
// [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
|
|
@@ -192,13 +192,12 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
192
192
|
const manifest = join(rt, 'hooks-manifest')
|
|
193
193
|
writeFileSync(manifest, compileManifest())
|
|
194
194
|
record('hook manifest', manifest)
|
|
195
|
-
// (2) the contract = the
|
|
196
|
-
// in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
|
|
195
|
+
// (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
|
|
197
196
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const contract =
|
|
197
|
+
// ONE source, no per-project escape hatch: the contract IS the surface:system plugin bodies. A project's
|
|
198
|
+
// own hand-written prose is not folded in — repo-local notes belong in the harness file's own
|
|
199
|
+
// block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
|
|
200
|
+
const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
|
|
202
201
|
// WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
|
|
203
202
|
// native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
204
203
|
const cfg = readConfig(mainCheckout(proj))
|
|
@@ -3,18 +3,14 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
|
3
3
|
import { tmpdir, userInfo } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { headlessTurnFailureShell, type DispatchResult, type HarnessDeliveryRecord } from './harness.js'
|
|
7
|
+
import { shQuote } from './sh.js'
|
|
8
8
|
|
|
9
9
|
const pexec = promisify(execFile)
|
|
10
|
-
const SPEX = join(fileURLToPath(new URL('..', import.meta.url)), 'bin', 'spex.mjs')
|
|
11
10
|
const WAKE_EARLY_EXIT_MS = 5_000
|
|
12
11
|
const OUTCOME_POLL_MS = 25
|
|
13
12
|
|
|
14
|
-
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
15
13
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
16
|
-
const turnFailureShell = (swallow: boolean) =>
|
|
17
|
-
`${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote('opencode-headless')} "$__spex_rc"${swallow ? ' || true' : ''}`
|
|
18
14
|
|
|
19
15
|
function accountLoginShell(): string {
|
|
20
16
|
try { return userInfo().shell || '' } catch { return '' }
|
|
@@ -33,12 +29,12 @@ function turnHome(command: string, outcomePath?: string): string {
|
|
|
33
29
|
'__spex_cas_rc=0',
|
|
34
30
|
'if [ "$__spex_rc" -ne 0 ]; then',
|
|
35
31
|
' __spex_outcome "reporting:$$:$__spex_rc" || true',
|
|
36
|
-
` ${
|
|
32
|
+
` ${headlessTurnFailureShell('opencode-headless', false)}`,
|
|
37
33
|
' __spex_cas_rc=$?',
|
|
38
34
|
'fi',
|
|
39
35
|
'__spex_outcome "exit:$__spex_rc:cas:$__spex_cas_rc" || printf "[spex opencode-headless] could not finalize turn outcome marker\\n" >&2',
|
|
40
36
|
] : [
|
|
41
|
-
`if [ "$__spex_rc" -ne 0 ]; then ${
|
|
37
|
+
`if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('opencode-headless')}; fi`,
|
|
42
38
|
]
|
|
43
39
|
const script = [
|
|
44
40
|
...outcomeSetup,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
2
2
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
3
|
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
8
|
+
import { controlRequest, withTimeout } from './headless-controller.js'
|
|
9
|
+
import { shQuote } from './sh.js'
|
|
8
10
|
|
|
9
11
|
type ControlRequest = { type: 'deliver'; text: string }
|
|
10
12
|
type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
|
|
@@ -14,8 +16,6 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
14
16
|
const CONTROL_TIMEOUT_MS = 30_000
|
|
15
17
|
const START_TIMEOUT_MS = 30_000
|
|
16
18
|
|
|
17
|
-
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
18
|
-
|
|
19
19
|
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
20
20
|
export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
|
|
21
21
|
|
|
@@ -23,53 +23,11 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
|
|
|
23
23
|
return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
29
|
-
promise.then(
|
|
30
|
-
(value) => { clearTimeout(timer); resolve(value) },
|
|
31
|
-
(error) => { clearTimeout(timer); reject(error) },
|
|
32
|
-
)
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
let socket: Socket | undefined
|
|
39
|
-
let settled = false
|
|
40
|
-
let buffer = ''
|
|
41
|
-
const finish = (result: DispatchResult) => {
|
|
42
|
-
if (settled) return
|
|
43
|
-
settled = true
|
|
44
|
-
clearTimeout(timer)
|
|
45
|
-
socket?.destroy()
|
|
46
|
-
resolve(result)
|
|
47
|
-
}
|
|
48
|
-
const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
|
|
49
|
-
try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
|
|
50
|
-
finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
socket.setEncoding('utf8')
|
|
54
|
-
socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
|
|
55
|
-
socket.on('data', (chunk) => {
|
|
56
|
-
buffer += chunk
|
|
57
|
-
const nl = buffer.indexOf('\n')
|
|
58
|
-
if (nl < 0) return
|
|
59
|
-
try {
|
|
60
|
-
const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
|
|
61
|
-
finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
|
|
62
|
-
} catch (error) {
|
|
63
|
-
finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
|
|
67
|
-
socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
26
|
export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
|
|
72
|
-
controlRequest(rec.session, { type: 'deliver', text }
|
|
27
|
+
controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
|
|
28
|
+
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
29
|
+
rejected: 'pi-headless controller rejected the request',
|
|
30
|
+
})
|
|
73
31
|
|
|
74
32
|
export class PiHeadlessController {
|
|
75
33
|
private server: Server | null = null
|
|
@@ -111,9 +69,10 @@ export class PiHeadlessController {
|
|
|
111
69
|
if (!this.server) return resolve()
|
|
112
70
|
this.server.close(() => resolve())
|
|
113
71
|
})
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
72
|
+
// same proof-before-removal rule as every other teardown (harness.ts unlinkSocks): a socket path is keyed
|
|
73
|
+
// by session id alone, so only a listener PROVEN dead is ours to unlink.
|
|
74
|
+
const { rvSock, unlinkSocks } = await import('./harness.js')
|
|
75
|
+
await unlinkSocks(this.socketPath, rvSock(this.id))
|
|
117
76
|
}
|
|
118
77
|
|
|
119
78
|
private accept(socket: Socket): void {
|
|
@@ -144,14 +103,12 @@ export class PiHeadlessController {
|
|
|
144
103
|
|
|
145
104
|
// A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
|
|
146
105
|
// shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
|
|
147
|
-
const {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await this.spawnTurn(request.text, true)
|
|
154
|
-
return { ok: true }
|
|
106
|
+
const { deliverViaSocketOrWake } = await import('./harness.js')
|
|
107
|
+
return deliverViaSocketOrWake(this.id, request.text, async () => {
|
|
108
|
+
if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
|
|
109
|
+
await this.spawnTurn(request.text, true)
|
|
110
|
+
return { ok: true }
|
|
111
|
+
}, `could not determine whether pi turn ${this.id} is live — prompt NOT delivered`)
|
|
155
112
|
}
|
|
156
113
|
|
|
157
114
|
private async spawnTurn(text: string, resume: boolean): Promise<void> {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { platform } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export type ProcessIdentity = { pid: number; startToken: string }
|
|
7
|
+
export type ProcessTopology = ProcessIdentity & { processGroupId: number; sessionId: number }
|
|
8
|
+
|
|
9
|
+
export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
|
|
10
|
+
const end = text.lastIndexOf(')')
|
|
11
|
+
if (end < 0) throw new Error('malformed proc stat')
|
|
12
|
+
const fields = text.slice(end + 2).trim().split(/\s+/)
|
|
13
|
+
if (fields.length < 22) throw new Error('short proc stat')
|
|
14
|
+
return {
|
|
15
|
+
ppid: Number(fields[1]),
|
|
16
|
+
processGroupId: Number(fields[2]),
|
|
17
|
+
sessionId: Number(fields[3]),
|
|
18
|
+
ticks: Number(fields[11]) + Number(fields[12]),
|
|
19
|
+
startToken: fields[19],
|
|
20
|
+
rssPages: Number(fields[21]),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function processTopology(pid: number, procRoot = '/proc'): ProcessTopology | null {
|
|
25
|
+
if (platform() === 'linux' || procRoot !== '/proc') {
|
|
26
|
+
try {
|
|
27
|
+
const stat = parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8'))
|
|
28
|
+
return { pid, startToken: stat.startToken, processGroupId: stat.processGroupId, sessionId: stat.sessionId }
|
|
29
|
+
} catch { return null }
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const [processGroupId, sessionId] = execFileSync('ps', ['-o', 'pgid=', '-o', 'sess=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/).map(Number)
|
|
33
|
+
const startToken = processStartToken(pid, procRoot)
|
|
34
|
+
return startToken && Number.isFinite(processGroupId) && Number.isFinite(sessionId)
|
|
35
|
+
? { pid, startToken, processGroupId, sessionId }
|
|
36
|
+
: null
|
|
37
|
+
} catch { return null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function processStartToken(pid: number, procRoot = '/proc'): string | null {
|
|
41
|
+
if (platform() === 'linux' || procRoot !== '/proc') {
|
|
42
|
+
try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
|
|
43
|
+
catch { return null }
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
|
|
47
|
+
return started || null
|
|
48
|
+
} catch { return null }
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// One pure identity seam for every project-scoped runtime consumer. Git discovery stays with callers;
|
|
5
|
+
// once they have the shared common dir, sessions, materialize, history indexes, backends, and uninstall
|
|
6
|
+
// all resolve the same top-level store.
|
|
7
|
+
export function spexcodeHome(): string {
|
|
8
|
+
return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodeProject(root: string): string {
|
|
12
|
+
return root.replace(/[/.]/g, '-')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function projectRuntimeRoot(commonDir: string): string {
|
|
16
|
+
return join(spexcodeHome(), 'projects', encodeProject(dirname(commonDir)))
|
|
17
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
|
-
import { alive } from './sessions.js'
|
|
4
|
+
import { alive, withSessionInputLock } from './sessions.js'
|
|
5
|
+
import { runSessionOperationSync } from './session-maintenance.js'
|
|
5
6
|
|
|
6
7
|
const pexec = promisify(execFile)
|
|
7
8
|
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
@@ -362,8 +363,10 @@ const MAX_INPUT_BYTES = 64 * 1024
|
|
|
362
363
|
export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
|
|
363
364
|
const subscription = currentSubscription(id, viewer)
|
|
364
365
|
if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
|
|
367
|
+
sendControl(subscription.bridge!, { t: 'input', data })
|
|
368
|
+
return true
|
|
369
|
+
}) ?? false)
|
|
367
370
|
}
|
|
368
371
|
|
|
369
372
|
async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
|
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -15,6 +15,11 @@ import { EVAL_QUERY_DEFAULT, ISSUE_QUERY_DEFAULT, readToken } from '../../spec-d
|
|
|
15
15
|
export const REVIEW_PER_PAGE = 25
|
|
16
16
|
|
|
17
17
|
type ReviewItem = Record<string, unknown>
|
|
18
|
+
// A section count is one number, or — when the domain adapter splits that section — its named buckets,
|
|
19
|
+
// which re-add to the same whole population ([[review-filters]] owns the split; Evals splits its measured
|
|
20
|
+
// verdicts into {fresh,stale} so the remeasurement debt travels with the count instead of being recomputed
|
|
21
|
+
// per surface). The fold happens ONCE, here on the server, over the complete filtered population.
|
|
22
|
+
export type ReviewCount = number | Record<string, number>
|
|
18
23
|
type ReviewOption = { value: string; label?: string; count?: number }
|
|
19
24
|
type ReviewFacet = { key: string; label?: string; value: string; meaningful?: boolean; options: ReviewOption[] }
|
|
20
25
|
type EvalNeighbor = { node: string; scenario: string; state: string }
|
|
@@ -29,7 +34,7 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
|
|
|
29
34
|
prev: number | null
|
|
30
35
|
next: number | null
|
|
31
36
|
revision: string
|
|
32
|
-
counts: Record<string,
|
|
37
|
+
counts: Record<string, ReviewCount>
|
|
33
38
|
facets: Record<string, ReviewFacet>
|
|
34
39
|
section: { key: string; value: string; options: ReviewOption[] } | null
|
|
35
40
|
}
|
|
@@ -129,12 +134,12 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
132
|
-
const byNewest = (a: any, b: any): number =>
|
|
137
|
+
const byNewest = (a: any, b: any): number => Number(b.filterKind === EVAL_FILTER_KIND.RESULT) - Number(a.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
138
|
+
|| String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
133
139
|
|| String(a.node ?? '').localeCompare(String(b.node ?? ''))
|
|
134
140
|
|| String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''))
|
|
135
141
|
|
|
136
142
|
export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
137
|
-
const blind: any[] = []
|
|
138
143
|
const items: any[] = []
|
|
139
144
|
for (const node of nodes ?? []) {
|
|
140
145
|
const latest = new Map<string, any>()
|
|
@@ -142,7 +147,7 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
|
142
147
|
for (const scenario of node.scenarios ?? []) {
|
|
143
148
|
const reading = latest.get(scenario.name)
|
|
144
149
|
if (!reading) {
|
|
145
|
-
|
|
150
|
+
items.push({
|
|
146
151
|
scenario: scenario.name,
|
|
147
152
|
expected: scenario.expected,
|
|
148
153
|
tags: scenario.tags,
|
|
@@ -163,21 +168,18 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
|
163
168
|
})
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
|
-
|
|
167
|
-
return [...blind, ...items.sort(byNewest)]
|
|
171
|
+
return items.sort(byNewest)
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
171
|
-
const
|
|
172
|
-
const own: any[] = []
|
|
173
|
-
const inherited: any[] = []
|
|
175
|
+
const items: any[] = []
|
|
174
176
|
for (const node of model.nodes ?? []) {
|
|
175
177
|
const latest = new Map<string, any>()
|
|
176
178
|
for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
177
179
|
for (const scenario of node.scenarios ?? []) {
|
|
178
180
|
const reading = latest.get(scenario.name)
|
|
179
181
|
if (!reading) {
|
|
180
|
-
|
|
182
|
+
items.push({
|
|
181
183
|
scenario: scenario.name,
|
|
182
184
|
expected: scenario.expected,
|
|
183
185
|
tags: scenario.tags,
|
|
@@ -192,15 +194,16 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
|
192
194
|
...reading,
|
|
193
195
|
expected: scenario.expected ?? reading.expected,
|
|
194
196
|
tags: scenario.tags,
|
|
197
|
+
impact: scenario.impact,
|
|
195
198
|
state: evalReviewState(reading),
|
|
196
199
|
node: node.id,
|
|
197
200
|
hue: node.hue,
|
|
198
201
|
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
199
202
|
}
|
|
200
|
-
|
|
203
|
+
items.push(item)
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
|
-
return
|
|
206
|
+
return items.sort(byNewest)
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
@@ -327,9 +330,10 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
327
330
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
328
331
|
summary: model.summary,
|
|
329
332
|
evalRevision: model.evalRevision,
|
|
333
|
+
impact: model.impact,
|
|
330
334
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
331
335
|
domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
|
|
332
|
-
evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
|
|
336
|
+
evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
|
|
333
337
|
}),
|
|
334
338
|
}
|
|
335
339
|
}
|