spexcode 0.5.2 → 0.5.4
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 +15 -4
- package/spec-cli/src/harness.ts +1133 -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 +698 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +73 -15
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +189 -41
- 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 +174 -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 +88 -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 +1251 -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 +29 -15
- 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,174 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { platform } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export type ProcessIdentity = { pid: number; startToken: string }
|
|
8
|
+
|
|
9
|
+
export type ProcessAdapter = Readonly<{
|
|
10
|
+
platform: NodeJS.Platform
|
|
11
|
+
startToken(pid: number): string | null
|
|
12
|
+
processGroupId(pid: number): number | null
|
|
13
|
+
linuxSessionId(pid: number): number | null
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
export type VerifiedDetachedRuntime = Readonly<ProcessIdentity & {
|
|
17
|
+
receiptVersion: 4
|
|
18
|
+
processGroupId: number
|
|
19
|
+
linuxSessionId?: number
|
|
20
|
+
}>
|
|
21
|
+
|
|
22
|
+
export type DetachedRuntimeVerification =
|
|
23
|
+
| { ok: true; identity: VerifiedDetachedRuntime }
|
|
24
|
+
| { ok: false; reason: string }
|
|
25
|
+
|
|
26
|
+
type DetachedLaunchReceiptV4 = {
|
|
27
|
+
version: 4
|
|
28
|
+
kind: 'spexcode-detached-runtime'
|
|
29
|
+
pid: number
|
|
30
|
+
startToken: string
|
|
31
|
+
processGroupId: number
|
|
32
|
+
linuxSessionId?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
|
|
36
|
+
const end = text.lastIndexOf(')')
|
|
37
|
+
if (end < 0) throw new Error('malformed proc stat')
|
|
38
|
+
const fields = text.slice(end + 2).trim().split(/\s+/)
|
|
39
|
+
if (fields.length < 22) throw new Error('short proc stat')
|
|
40
|
+
return {
|
|
41
|
+
ppid: Number(fields[1]),
|
|
42
|
+
processGroupId: Number(fields[2]),
|
|
43
|
+
sessionId: Number(fields[3]),
|
|
44
|
+
ticks: Number(fields[11]) + Number(fields[12]),
|
|
45
|
+
startToken: fields[19],
|
|
46
|
+
rssPages: Number(fields[21]),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function processStartToken(pid: number, procRoot = '/proc'): string | null {
|
|
51
|
+
if (platform() === 'linux' || procRoot !== '/proc') {
|
|
52
|
+
try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
|
|
53
|
+
catch { return null }
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
|
|
57
|
+
return started || null
|
|
58
|
+
} catch { return null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const procField = (pid: number, field: 'processGroupId' | 'sessionId'): number | null => {
|
|
62
|
+
try {
|
|
63
|
+
const stat = parseProcStat(readFileSync(join('/proc', String(pid), 'stat'), 'utf8'))
|
|
64
|
+
return stat[field]
|
|
65
|
+
} catch { return null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const psProcessGroupId = (pid: number): number | null => {
|
|
69
|
+
try {
|
|
70
|
+
const value = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim())
|
|
71
|
+
return Number.isInteger(value) && value > 0 ? value : null
|
|
72
|
+
} catch { return null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const hostProcessAdapter: ProcessAdapter = Object.freeze({
|
|
76
|
+
platform: platform(),
|
|
77
|
+
startToken: (pid) => processStartToken(pid),
|
|
78
|
+
processGroupId: (pid) => platform() === 'linux' ? procField(pid, 'processGroupId') : psProcessGroupId(pid),
|
|
79
|
+
linuxSessionId: (pid) => platform() === 'linux' ? procField(pid, 'sessionId') : null,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const exactKeys = (value: Record<string, unknown>, expected: string[]) => {
|
|
83
|
+
const actual = Object.keys(value).sort()
|
|
84
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parseDetachedLaunchReceipt = (text: string, hostPlatform: NodeJS.Platform): DetachedLaunchReceiptV4 | null => {
|
|
88
|
+
let value: unknown
|
|
89
|
+
try { value = JSON.parse(text) } catch { return null }
|
|
90
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
91
|
+
const receipt = value as Record<string, unknown>
|
|
92
|
+
const keys = ['kind', 'pid', 'processGroupId', 'startToken', 'version', ...(hostPlatform === 'linux' ? ['linuxSessionId'] : [])].sort()
|
|
93
|
+
if (!exactKeys(receipt, keys) || receipt.version !== 4 || receipt.kind !== 'spexcode-detached-runtime' ||
|
|
94
|
+
!Number.isInteger(receipt.pid) || Number(receipt.pid) <= 0 || typeof receipt.startToken !== 'string' || !receipt.startToken ||
|
|
95
|
+
!Number.isInteger(receipt.processGroupId) || Number(receipt.processGroupId) <= 0 ||
|
|
96
|
+
(hostPlatform === 'linux' && (!Number.isInteger(receipt.linuxSessionId) || Number(receipt.linuxSessionId) <= 0))) return null
|
|
97
|
+
return receipt as DetachedLaunchReceiptV4
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const observeDetachedRuntime = (pid: number, adapter: ProcessAdapter): DetachedRuntimeVerification => {
|
|
101
|
+
if (adapter.platform === 'win32') return { ok: false, reason: 'detached runtime identity is unsupported on win32' }
|
|
102
|
+
const startBefore = adapter.startToken(pid)
|
|
103
|
+
if (!startBefore) return { ok: false, reason: `PID ${pid} has no readable process-start identity` }
|
|
104
|
+
const processGroupId = adapter.processGroupId(pid)
|
|
105
|
+
if (processGroupId !== pid) return { ok: false, reason: `PID ${pid} is not its own process-group leader (pgrp=${processGroupId ?? 'unknown'})` }
|
|
106
|
+
let linuxSessionId: number | undefined
|
|
107
|
+
if (adapter.platform === 'linux') {
|
|
108
|
+
const sessionId = adapter.linuxSessionId(pid)
|
|
109
|
+
if (sessionId !== pid) return { ok: false, reason: `Linux PID ${pid} is not its own session leader (session=${sessionId ?? 'unknown'})` }
|
|
110
|
+
linuxSessionId = sessionId
|
|
111
|
+
}
|
|
112
|
+
const startAfter = adapter.startToken(pid)
|
|
113
|
+
if (!startAfter || startAfter !== startBefore)
|
|
114
|
+
return { ok: false, reason: `PID ${pid} process-start identity changed during detached-boundary verification` }
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
identity: Object.freeze({
|
|
118
|
+
pid,
|
|
119
|
+
startToken: startAfter,
|
|
120
|
+
receiptVersion: 4,
|
|
121
|
+
processGroupId,
|
|
122
|
+
...(linuxSessionId === undefined ? {} : { linuxSessionId }),
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): DetachedRuntimeVerification {
|
|
128
|
+
let text: string
|
|
129
|
+
try { text = readFileSync(receiptFile, 'utf8') }
|
|
130
|
+
catch { return { ok: false, reason: 'detached launch receipt is missing or unreadable' } }
|
|
131
|
+
const receipt = parseDetachedLaunchReceipt(text, adapter.platform)
|
|
132
|
+
if (!receipt) return { ok: false, reason: 'detached launch receipt has the wrong version or shape' }
|
|
133
|
+
if (receipt.pid !== pid) return { ok: false, reason: `detached launch receipt names PID ${receipt.pid}, not ${pid}` }
|
|
134
|
+
if (receipt.processGroupId !== pid)
|
|
135
|
+
return { ok: false, reason: `detached launch receipt has wrong process group ${receipt.processGroupId} for PID ${pid}` }
|
|
136
|
+
if (adapter.platform === 'linux' && receipt.linuxSessionId !== pid)
|
|
137
|
+
return { ok: false, reason: `detached launch receipt has wrong Linux session ${receipt.linuxSessionId ?? 'missing'} for PID ${pid}` }
|
|
138
|
+
const live = observeDetachedRuntime(pid, adapter)
|
|
139
|
+
if (!live.ok) return live
|
|
140
|
+
if (live.identity.startToken !== receipt.startToken)
|
|
141
|
+
return { ok: false, reason: `PID ${pid} process-start identity does not match its detached launch receipt` }
|
|
142
|
+
return live
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function writeDetachedRuntimeReceipt(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): VerifiedDetachedRuntime {
|
|
146
|
+
const observed = observeDetachedRuntime(pid, adapter)
|
|
147
|
+
if (!observed.ok) throw new Error(`cannot prove detached shared runtime: ${observed.reason}`)
|
|
148
|
+
const receipt: DetachedLaunchReceiptV4 = {
|
|
149
|
+
version: 4,
|
|
150
|
+
kind: 'spexcode-detached-runtime',
|
|
151
|
+
pid,
|
|
152
|
+
startToken: observed.identity.startToken,
|
|
153
|
+
processGroupId: observed.identity.processGroupId,
|
|
154
|
+
...(observed.identity.linuxSessionId === undefined ? {} : { linuxSessionId: observed.identity.linuxSessionId }),
|
|
155
|
+
}
|
|
156
|
+
mkdirSync(dirname(receiptFile), { recursive: true })
|
|
157
|
+
const tmp = `${receiptFile}.${process.pid}.${randomUUID()}.tmp`
|
|
158
|
+
let published = false
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(tmp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 })
|
|
161
|
+
renameSync(tmp, receiptFile)
|
|
162
|
+
published = true
|
|
163
|
+
const verified = verifyDetachedRuntime(pid, receiptFile, adapter)
|
|
164
|
+
if (!verified.ok) throw new Error(`cannot verify detached shared runtime receipt: ${verified.reason}`)
|
|
165
|
+
return verified.identity
|
|
166
|
+
} catch (error) {
|
|
167
|
+
rmSync(tmp, { force: true })
|
|
168
|
+
if (published) rmSync(receiptFile, { force: true })
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const detachedRuntimeGenerationToken = (identity: VerifiedDetachedRuntime) =>
|
|
174
|
+
`detached-v4|${identity.pid}|${identity.startToken}|${identity.processGroupId}|${identity.linuxSessionId ?? '-'}`
|
|
@@ -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
|
+
}
|