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.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
@@ -0,0 +1,750 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { createHash, randomBytes as cryptoRandomBytes, timingSafeEqual } from 'node:crypto'
4
+ import { closeSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
5
+ import { platform } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { runtimeRoot as projectRuntimeRoot } from './layout.js'
8
+ import { parseProcStat, processStartToken } from './process-identity.js'
9
+
10
+ export type ProcessIdentity = { pid: number; startToken: string }
11
+ export type LeaseOwner = ProcessIdentity & { instanceId: string }
12
+ export type ProcessReading = ProcessIdentity | null | 'ambiguous'
13
+ export type Capability =
14
+ | { op: 'stop'; sessionId: string }
15
+ | { op: 'resume'; sessionId: string; force: boolean }
16
+ export type Authorization = { token: string; epoch: number }
17
+ export type Operation =
18
+ | { op: 'create' }
19
+ | { op: 'fallback-create' }
20
+ | { op: 'lifecycle-transition'; sessionId: string }
21
+ | { op: 'hook-state'; sessionId: string }
22
+ | { op: 'send'; sessionId: string }
23
+ | { op: 'raw-key-input'; sessionId: string }
24
+ | { op: 'terminal-input'; sessionId: string }
25
+ | { op: 'interrupt'; sessionId: string }
26
+ | { op: 'rename'; sessionId: string }
27
+ | { op: 'sort'; sessionId: string }
28
+ | ({ op: 'stop'; sessionId: string } & Partial<{ authorization: Authorization }>)
29
+ | ({ op: 'resume'; sessionId: string; force: boolean } & Partial<{ authorization: Authorization }>)
30
+ | { op: 'archive'; sessionId: string }
31
+ | { op: 'close'; sessionId: string }
32
+ | { op: 'merge-dispatch'; sessionId: string }
33
+ | { op: 'queue-drain' }
34
+ | { op: 'attach'; sessionId: string }
35
+ | { op: 'shared-spawn'; sessionId: string; delegate?: string }
36
+
37
+ type CapabilityState = 'unused' | 'inflight' | 'committed' | 'indeterminate'
38
+ type CapabilityEntry = { capability: Capability; state: CapabilityState; requestId?: string }
39
+ type Ticket = {
40
+ id: string
41
+ epoch: number
42
+ operation: Operation['op']
43
+ sessionId?: string
44
+ force?: boolean
45
+ owner: ProcessIdentity
46
+ deadline: number
47
+ mode?: 'ordinary' | 'maintenance'
48
+ parentTicketId?: string
49
+ }
50
+ type Delegate = {
51
+ tokenHash: string
52
+ parentTicketId: string
53
+ epoch: number
54
+ operation: 'shared-spawn'
55
+ sessionId: string
56
+ state: 'unused' | 'running' | 'completed'
57
+ }
58
+ type DurableState = {
59
+ version: 1
60
+ state: 'open' | 'draining' | 'active'
61
+ epoch: number
62
+ tokenHash: string | null
63
+ owner: LeaseOwner | null
64
+ heartbeatDeadline: number | null
65
+ capabilities: CapabilityEntry[]
66
+ tickets: Ticket[]
67
+ delegates: Delegate[]
68
+ }
69
+ export type MaintenanceTicket = {
70
+ id: string
71
+ epoch: number
72
+ delegateSharedSpawn(sessionId: string): string
73
+ }
74
+ export type MaintenanceLease = {
75
+ token: string
76
+ epoch: number
77
+ state: 'draining' | 'active'
78
+ owner: LeaseOwner
79
+ capabilities: readonly Capability[]
80
+ }
81
+ export type MaintenanceState = {
82
+ state: DurableState['state']
83
+ epoch: number
84
+ owner: LeaseOwner | null
85
+ heartbeatDeadline: number | null
86
+ tickets: readonly {
87
+ operation: Operation['op']
88
+ sessionId?: string
89
+ force?: boolean
90
+ owner: ProcessIdentity
91
+ deadline: number
92
+ }[]
93
+ capabilities: readonly CapabilityEntry[]
94
+ }
95
+
96
+ type CoordinatorInput = {
97
+ runtimeRoot: string
98
+ now: () => number
99
+ randomBytes: (size: number) => Buffer
100
+ processIdentity: (pid: number) => ProcessReading
101
+ selfIdentity: () => ProcessIdentity
102
+ ticketReportMs: number
103
+ onEvent?: (event: Record<string, unknown>) => void
104
+ }
105
+
106
+ const OPERATIONS = new Set<Operation['op']>([
107
+ 'create', 'fallback-create', 'lifecycle-transition', 'hook-state', 'send', 'raw-key-input',
108
+ 'terminal-input', 'interrupt', 'rename', 'sort', 'stop', 'resume', 'archive', 'close',
109
+ 'merge-dispatch', 'queue-drain', 'attach', 'shared-spawn',
110
+ ])
111
+ const MIN_TTL_MS = 5_000
112
+ const MAX_TTL_MS = 300_000
113
+ const MAX_WAIT_MS = 120_000
114
+ const LOCK_WAIT_MS = 5_000
115
+ const syncPause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
116
+ const context = new AsyncLocalStorage<{ ticketId: string }>()
117
+
118
+ export class SessionMaintenanceError extends Error {
119
+ readonly code: string
120
+ readonly state?: DurableState['state']
121
+ readonly epoch?: number
122
+ readonly operation?: Operation['op']
123
+ readonly sessionId?: string
124
+
125
+ constructor(code: string, message: string, details: Partial<Pick<SessionMaintenanceError, 'state' | 'epoch' | 'operation' | 'sessionId'>> = {}) {
126
+ super(message)
127
+ this.name = 'SessionMaintenanceError'
128
+ this.code = code
129
+ Object.assign(this, details)
130
+ }
131
+ }
132
+
133
+ const fail = (code: string, message: string, details?: Partial<Pick<SessionMaintenanceError, 'state' | 'epoch' | 'operation' | 'sessionId'>>): never => {
134
+ throw new SessionMaintenanceError(code, message, details)
135
+ }
136
+ const emptyState = (): DurableState => ({
137
+ version: 1, state: 'open', epoch: 0, tokenHash: null, owner: null, heartbeatDeadline: null,
138
+ capabilities: [], tickets: [], delegates: [],
139
+ })
140
+ const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
141
+ const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
142
+ const sameIdentity = (a: ProcessIdentity | null | undefined, b: ProcessIdentity | null | undefined): boolean =>
143
+ !!a && !!b && a.pid === b.pid && a.startToken === b.startToken
144
+ const operationSession = (operation: Operation): string | undefined => 'sessionId' in operation ? operation.sessionId : undefined
145
+ const operationForce = (operation: Operation): boolean | undefined => operation.op === 'resume' ? operation.force : undefined
146
+
147
+ function canonicalCapabilities(input: readonly Capability[]): Capability[] {
148
+ if (!Array.isArray(input)) fail('maintenance_invalid', 'maintenance capabilities must be an array')
149
+ const seen = new Set<string>()
150
+ return input.map((raw) => {
151
+ if (!raw || typeof raw !== 'object' || typeof raw.sessionId !== 'string' || !raw.sessionId.trim())
152
+ fail('maintenance_invalid', 'maintenance capability needs an exact session id')
153
+ const capability: Capability = raw.op === 'stop' && Object.keys(raw).every((key) => key === 'op' || key === 'sessionId')
154
+ ? { op: 'stop', sessionId: raw.sessionId }
155
+ : raw.op === 'resume' && typeof raw.force === 'boolean'
156
+ && Object.keys(raw).every((key) => key === 'op' || key === 'sessionId' || key === 'force')
157
+ ? { op: 'resume', sessionId: raw.sessionId, force: raw.force }
158
+ : fail('maintenance_invalid', 'maintenance capability must be exact stop or resume authority')
159
+ const key = `${capability.op}\0${capability.sessionId}`
160
+ if (seen.has(key)) fail('maintenance_invalid', `duplicate maintenance capability for ${capability.op} ${capability.sessionId}`)
161
+ seen.add(key)
162
+ return capability
163
+ })
164
+ }
165
+
166
+ function normalizeState(raw: unknown): DurableState {
167
+ if (!raw || typeof raw !== 'object') fail('maintenance_state_invalid', 'session maintenance state is unreadable')
168
+ const value = raw as Partial<DurableState>
169
+ if (value.version !== 1 || !['open', 'draining', 'active'].includes(String(value.state)) || !Number.isSafeInteger(value.epoch) || Number(value.epoch) < 0)
170
+ fail('maintenance_state_invalid', 'session maintenance state has an invalid version, state, or epoch')
171
+ return {
172
+ version: 1,
173
+ state: value.state as DurableState['state'],
174
+ epoch: Number(value.epoch),
175
+ tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : null,
176
+ owner: value.owner && typeof value.owner.instanceId === 'string' && value.owner.instanceId
177
+ && Number.isInteger(value.owner.pid) && typeof value.owner.startToken === 'string'
178
+ ? copy(value.owner as LeaseOwner)
179
+ : null,
180
+ heartbeatDeadline: typeof value.heartbeatDeadline === 'number' ? value.heartbeatDeadline : null,
181
+ capabilities: Array.isArray(value.capabilities) ? copy(value.capabilities) : [],
182
+ tickets: Array.isArray(value.tickets) ? copy(value.tickets) : [],
183
+ delegates: Array.isArray(value.delegates) ? copy(value.delegates) : [],
184
+ }
185
+ }
186
+
187
+ function presentedTokenMatches(token: string, expectedHash: string | null): boolean {
188
+ const actual = createHash('sha256').update(token).digest()
189
+ const valid = typeof expectedHash === 'string' && /^[0-9a-f]{64}$/i.test(expectedHash)
190
+ const expected = valid ? Buffer.from(expectedHash!, 'hex') : Buffer.alloc(actual.length)
191
+ return timingSafeEqual(actual, expected) && valid
192
+ }
193
+
194
+ function parentPid(pid: number): number | null {
195
+ if (platform() === 'linux') {
196
+ try { return parseProcStat(readFileSync(`/proc/${pid}/stat`, 'utf8')).ppid } catch { return null }
197
+ }
198
+ try {
199
+ const value = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8' }).trim())
200
+ return Number.isInteger(value) && value > 0 ? value : null
201
+ } catch { return null }
202
+ }
203
+
204
+ function isDescendantOf(pid: number, owner: ProcessIdentity, readIdentity: (pid: number) => ProcessReading): boolean {
205
+ const ownerReading = readIdentity(owner.pid)
206
+ if (ownerReading === 'ambiguous' || !sameIdentity(ownerReading, owner)) return false
207
+ let current = pid
208
+ for (let depth = 0; depth < 64 && current > 1; depth++) {
209
+ if (current === owner.pid) return true
210
+ const parent = parentPid(current)
211
+ if (!parent || parent === current) return false
212
+ current = parent
213
+ }
214
+ return false
215
+ }
216
+
217
+ export function createSessionMaintenance(input: CoordinatorInput) {
218
+ const statePath = join(input.runtimeRoot, 'session-maintenance.json')
219
+ const lockPath = join(input.runtimeRoot, '.session-maintenance.lock')
220
+ const lockPrefix = '.session-maintenance.lock.reap-'
221
+ mkdirSync(input.runtimeRoot, { recursive: true })
222
+
223
+ const identityState = (owner: ProcessIdentity): 'live' | 'dead' | 'reused' | 'ambiguous' => {
224
+ const reading = input.processIdentity(owner.pid)
225
+ if (reading === 'ambiguous') return 'ambiguous'
226
+ if (reading === null) return 'dead'
227
+ return sameIdentity(reading, owner) ? 'live' : 'reused'
228
+ }
229
+
230
+ type LockRecord = { version: 1; nonce: string; owner: ProcessIdentity }
231
+ const readLock = (path = lockPath): LockRecord | null => {
232
+ try {
233
+ const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<LockRecord>
234
+ return raw.version === 1 && typeof raw.nonce === 'string' && raw.nonce
235
+ && raw.owner && Number.isInteger(raw.owner.pid) && typeof raw.owner.startToken === 'string'
236
+ ? raw as LockRecord
237
+ : null
238
+ } catch { return null }
239
+ }
240
+ const writeLinkedRecord = (path: string, record: LockRecord): boolean => {
241
+ const temp = join(input.runtimeRoot, `.session-maintenance.owner-${process.pid}-${record.nonce}.tmp`)
242
+ let fd: number | null = null
243
+ try {
244
+ fd = openSync(temp, 'wx', 0o600)
245
+ writeFileSync(fd, JSON.stringify(record))
246
+ fsyncSync(fd)
247
+ closeSync(fd); fd = null
248
+ linkSync(temp, path)
249
+ return true
250
+ } catch (error) {
251
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
252
+ return false
253
+ } finally {
254
+ if (fd !== null) closeSync(fd)
255
+ try { unlinkSync(temp) } catch { /* linked or already absent */ }
256
+ }
257
+ }
258
+ const reapMarkers = (): string[] => {
259
+ try { return readdirSync(input.runtimeRoot).filter((name) => name.startsWith(lockPrefix)) }
260
+ catch { return [] }
261
+ }
262
+ const sameLockRecord = (left: LockRecord | null, right: LockRecord): boolean =>
263
+ !!left && left.nonce === right.nonce && sameIdentity(left.owner, right.owner)
264
+ const reclaimDeadRecord = (path: string, expected: LockRecord, depth = 0): boolean => {
265
+ if (depth >= 64) fail('maintenance_conflict', 'session maintenance lock claim depth exceeded')
266
+ if (!sameLockRecord(readLock(path), expected) || !['dead', 'reused'].includes(identityState(expected.owner))) return false
267
+ const claimPath = `${path}.claim`
268
+ const self = input.selfIdentity()
269
+ const claim: LockRecord = { version: 1, nonce: input.randomBytes(16).toString('hex'), owner: self }
270
+ if (!writeLinkedRecord(claimPath, claim)) {
271
+ const existing = readLock(claimPath)
272
+ if (!existing || !['dead', 'reused'].includes(identityState(existing.owner))) return false
273
+ if (!reclaimDeadRecord(claimPath, existing, depth + 1)) return false
274
+ return reclaimDeadRecord(path, expected, depth + 1)
275
+ }
276
+ try {
277
+ const confirmed = readLock(path)
278
+ if (!sameLockRecord(confirmed, expected) || !['dead', 'reused'].includes(identityState(expected.owner))) return false
279
+ unlinkSync(path)
280
+ return true
281
+ } finally {
282
+ if (sameLockRecord(readLock(claimPath), claim)) unlinkSync(claimPath)
283
+ }
284
+ }
285
+ const clearDeadMarkers = (): boolean => {
286
+ for (const name of reapMarkers()) {
287
+ const path = join(input.runtimeRoot, name)
288
+ const marker = readLock(path)
289
+ if (marker && ['dead', 'reused'].includes(identityState(marker.owner))) reclaimDeadRecord(path, marker)
290
+ }
291
+ return reapMarkers().length === 0
292
+ }
293
+
294
+ const acquireLock = (): (() => void) => {
295
+ const deadline = Date.now() + LOCK_WAIT_MS
296
+ const self = input.selfIdentity()
297
+ const nonce = input.randomBytes(16).toString('hex')
298
+ const own: LockRecord = { version: 1, nonce, owner: self }
299
+ for (;;) {
300
+ if (clearDeadMarkers() && writeLinkedRecord(lockPath, own)) {
301
+ return () => {
302
+ const current = readLock()
303
+ if (!current || current.nonce !== nonce || !sameIdentity(current.owner, self))
304
+ fail('maintenance_conflict', 'session maintenance lock ownership changed before release')
305
+ unlinkSync(lockPath)
306
+ }
307
+ }
308
+
309
+ const current = readLock()
310
+ if (!current) {
311
+ // Legacy mkdir-before-owner residue is the old crash window this protocol removes. It has no
312
+ // complete identity that can authorize a state mutation, so remove only an empty directory.
313
+ try { if (readdirSync(lockPath).length === 0) rmSync(lockPath, { recursive: true }) } catch { /* file or live legacy owner */ }
314
+ } else if (['dead', 'reused'].includes(identityState(current.owner))) {
315
+ const markerPath = join(input.runtimeRoot, `${lockPrefix}${current.nonce}`)
316
+ const marker: LockRecord = { version: 1, nonce: input.randomBytes(16).toString('hex'), owner: self }
317
+ if (writeLinkedRecord(markerPath, marker)) {
318
+ try {
319
+ const confirmed = readLock()
320
+ if (confirmed?.nonce === current.nonce && sameIdentity(confirmed.owner, current.owner)
321
+ && ['dead', 'reused'].includes(identityState(confirmed.owner))) unlinkSync(lockPath)
322
+ } finally {
323
+ const claim = readLock(markerPath)
324
+ if (claim?.nonce === marker.nonce && sameIdentity(claim.owner, self)) unlinkSync(markerPath)
325
+ }
326
+ continue
327
+ }
328
+ }
329
+ if (Date.now() >= deadline) fail('maintenance_conflict', 'session maintenance state lock is held by a live or ambiguous owner')
330
+ syncPause(5)
331
+ }
332
+ }
333
+
334
+ const load = (): { state: DurableState; exists: boolean } => {
335
+ if (!existsSync(statePath)) return { state: emptyState(), exists: false }
336
+ try { return { state: normalizeState(JSON.parse(readFileSync(statePath, 'utf8'))), exists: true } }
337
+ catch (error) {
338
+ if (error instanceof SessionMaintenanceError) throw error
339
+ return fail('maintenance_state_invalid', `session maintenance state is unreadable: ${(error as Error).message}`)
340
+ }
341
+ }
342
+ const persist = (state: DurableState): void => {
343
+ const temp = `${statePath}.${process.pid}.${Date.now()}.tmp`
344
+ writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
345
+ renameSync(temp, statePath)
346
+ }
347
+
348
+ const recover = (state: DurableState, protectedTicketId?: string): boolean => {
349
+ let dirty = false
350
+ const retained: Ticket[] = []
351
+ for (const ticket of state.tickets) {
352
+ const ownerState = ticket.id === protectedTicketId ? 'live' : identityState(ticket.owner)
353
+ if (ownerState === 'live' || ownerState === 'ambiguous') {
354
+ retained.push(ticket)
355
+ continue
356
+ }
357
+ dirty = true
358
+ if (ticket.mode === 'maintenance') {
359
+ const entry = state.capabilities.find(({ capability }) => capability.op === ticket.operation
360
+ && capability.sessionId === ticket.sessionId
361
+ && (capability.op !== 'resume' || capability.force === ticket.force))
362
+ if (entry?.state === 'inflight' && entry.requestId === ticket.id) {
363
+ entry.state = 'indeterminate'
364
+ delete entry.requestId
365
+ }
366
+ }
367
+ for (const delegate of state.delegates) if (delegate.parentTicketId === ticket.id && delegate.state !== 'completed') delegate.state = 'completed'
368
+ }
369
+ if (retained.length !== state.tickets.length) state.tickets = retained
370
+
371
+ if (state.state !== 'open' && state.tokenHash && state.owner) {
372
+ const ownerState = identityState(state.owner)
373
+ if (ownerState === 'dead' || ownerState === 'reused' || input.now() > (state.heartbeatDeadline ?? -Infinity)) {
374
+ state.state = 'draining'
375
+ state.tokenHash = null
376
+ state.owner = null
377
+ state.heartbeatDeadline = null
378
+ for (const entry of state.capabilities) if (entry.state === 'inflight') {
379
+ entry.state = 'indeterminate'
380
+ delete entry.requestId
381
+ }
382
+ dirty = true
383
+ }
384
+ }
385
+ if (state.state === 'draining' && state.tickets.length === 0) {
386
+ if (state.tokenHash && state.owner && input.now() <= (state.heartbeatDeadline ?? -Infinity)) state.state = 'active'
387
+ else {
388
+ state.state = 'open'
389
+ state.tokenHash = null
390
+ state.owner = null
391
+ state.heartbeatDeadline = null
392
+ state.capabilities = []
393
+ state.delegates = []
394
+ }
395
+ dirty = true
396
+ }
397
+ return dirty
398
+ }
399
+
400
+ const locked = <T>(body: (state: DurableState, dirty: () => void) => T, beforeRecover?: (state: DurableState) => string | undefined): T => {
401
+ const release = acquireLock()
402
+ try {
403
+ const loaded = load()
404
+ let changed = !loaded.exists
405
+ const protectedTicketId = beforeRecover?.(loaded.state)
406
+ if (recover(loaded.state, protectedTicketId)) changed = true
407
+ const value = body(loaded.state, () => { changed = true })
408
+ if (changed) persist(loaded.state)
409
+ return value
410
+ } finally { release() }
411
+ }
412
+
413
+ locked((_state, dirty) => { if (!existsSync(statePath)) dirty() })
414
+
415
+ const verifyLease = (state: DurableState, authorization: Authorization): void => {
416
+ if (state.state !== 'active' || authorization.epoch !== state.epoch)
417
+ fail('maintenance_conflict', 'maintenance lease epoch is not active', { state: state.state, epoch: state.epoch })
418
+ if (!presentedTokenMatches(authorization.token, state.tokenHash))
419
+ fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch })
420
+ }
421
+ const capabilityFor = (state: DurableState, operation: Operation) => state.capabilities.find(({ capability }) =>
422
+ capability.op === operation.op && capability.sessionId === operationSession(operation)
423
+ && (capability.op !== 'resume' || capability.force === operationForce(operation)))
424
+
425
+ const inheritedOrdinaryParent = (state: DurableState): Ticket | null => {
426
+ const local = context.getStore()?.ticketId
427
+ const inherited = process.env.SPEXCODE_MAINTENANCE_PARENT_TICKET?.trim()
428
+ const id = local || inherited
429
+ if (!id) return null
430
+ const ticket = state.tickets.find((candidate) => candidate.id === id && candidate.mode !== 'maintenance')
431
+ if (!ticket) return null
432
+ if (local) return ticket
433
+ return isDescendantOf(process.pid, ticket.owner, input.processIdentity) ? ticket : null
434
+ }
435
+
436
+ const sharedSpawnAuthority = (state: DurableState, operation: Extract<Operation, { op: 'shared-spawn' }>): { delegate: Delegate; parentTicket: Ticket } => {
437
+ const raw = 'delegate' in operation ? operation.delegate : undefined
438
+ const delegate = typeof raw === 'string' ? state.delegates.find((candidate) => candidate.epoch === state.epoch
439
+ && candidate.operation === 'shared-spawn' && candidate.sessionId === operation.sessionId
440
+ && presentedTokenMatches(raw, candidate.tokenHash)) : undefined
441
+ const parentTicket = delegate && state.tickets.find((candidate) => candidate.id === delegate.parentTicketId
442
+ && candidate.epoch === state.epoch && candidate.operation === 'resume'
443
+ && candidate.sessionId === operation.sessionId && candidate.mode === 'maintenance')
444
+ if (!delegate || delegate.state !== 'unused' || !parentTicket || identityState(parentTicket.owner) !== 'live')
445
+ return fail('maintenance_delegate_invalid', 'shared-runtime delegate is forged, stale, completed, mismatched, replayed, or detached from its live resume owner', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
446
+ return { delegate, parentTicket }
447
+ }
448
+
449
+ const beginTicket = (state: DurableState, operation: Operation, owner: ProcessIdentity, markDirty: () => void): Ticket => {
450
+ if (!OPERATIONS.has(operation.op)) fail('maintenance_invalid', `unknown maintenance operation ${(operation as { op?: string }).op ?? ''}`)
451
+ const parent = inheritedOrdinaryParent(state)
452
+ const ticketId = input.randomBytes(16).toString('hex')
453
+ let mode: Ticket['mode'] = 'ordinary'
454
+ let parentTicketId: string | undefined
455
+
456
+ if (operation.op === 'shared-spawn') {
457
+ if (state.state === 'open') {
458
+ if ('delegate' in operation) fail('maintenance_delegate_invalid', 'shared-runtime delegate channel is not valid outside active maintenance', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
459
+ } else if (state.state !== 'active') {
460
+ fail('maintenance_active', `session maintenance is ${state.state}; shared-spawn was not admitted`, { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
461
+ } else {
462
+ const { delegate, parentTicket } = sharedSpawnAuthority(state, operation)
463
+ delegate.state = 'running'
464
+ mode = 'maintenance'
465
+ parentTicketId = parentTicket.id
466
+ }
467
+ } else if ((operation.op === 'stop' || operation.op === 'resume') && operation.authorization) {
468
+ verifyLease(state, operation.authorization)
469
+ const entry = capabilityFor(state, operation)
470
+ if (!entry) return fail('maintenance_capability_missing', 'operation is not in the exact maintenance capability plan', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
471
+ if (entry.state !== 'unused') fail('maintenance_capability_used', 'maintenance capability is already inflight, committed, or indeterminate', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
472
+ entry.state = 'inflight'
473
+ entry.requestId = ticketId
474
+ mode = 'maintenance'
475
+ } else if (state.state !== 'open' && (operation.op === 'stop' || operation.op === 'resume')) {
476
+ if (state.state === 'active')
477
+ return fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
478
+ fail('maintenance_active', `session maintenance is ${state.state}; ${operation.op} was not admitted`, { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operation.sessionId })
479
+ } else if (state.state === 'active' && 'authorization' in operation && operation.authorization) {
480
+ verifyLease(state, operation.authorization)
481
+ fail('maintenance_capability_missing', 'operation is not in the exact maintenance capability plan', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
482
+ } else if (state.state !== 'open' && parent) parentTicketId = parent.id
483
+ else if (state.state !== 'open') {
484
+ if ('delegate' in operation) fail('maintenance_delegate_invalid', 'shared-runtime delegate is not valid for this operation', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
485
+ fail('maintenance_active', `session maintenance is ${state.state}; ${operation.op} was not admitted`, { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
486
+ }
487
+
488
+ const ticket: Ticket = {
489
+ id: ticketId, epoch: state.epoch, operation: operation.op,
490
+ ...(operationSession(operation) ? { sessionId: operationSession(operation) } : {}),
491
+ ...(operationForce(operation) !== undefined ? { force: operationForce(operation) } : {}),
492
+ owner, deadline: input.now() + input.ticketReportMs, mode,
493
+ ...(parentTicketId ? { parentTicketId } : {}),
494
+ }
495
+ state.tickets.push(ticket)
496
+ markDirty()
497
+ return ticket
498
+ }
499
+
500
+ const beginOperationTicket = (operation: Operation, owner: ProcessIdentity): Ticket => locked(
501
+ (state, dirty) => beginTicket(state, operation, owner, dirty),
502
+ (state) => {
503
+ if (operation.op !== 'shared-spawn' || state.state !== 'active') return undefined
504
+ // Protect the proven-live parent from recovery, then re-read it in beginTicket before delegate mutation.
505
+ return sharedSpawnAuthority(state, operation).parentTicket.id
506
+ },
507
+ )
508
+
509
+ const finishTicket = (ticketId: string, outcome: 'committed' | 'retryable' | 'indeterminate'): void => locked((state, dirty) => {
510
+ const index = state.tickets.findIndex((ticket) => ticket.id === ticketId)
511
+ if (index < 0) return
512
+ const [ticket] = state.tickets.splice(index, 1)
513
+ if (ticket.mode === 'maintenance' && (ticket.operation === 'stop' || ticket.operation === 'resume')) {
514
+ const entry = state.capabilities.find(({ capability }) => capability.op === ticket.operation
515
+ && capability.sessionId === ticket.sessionId && (capability.op !== 'resume' || capability.force === ticket.force))
516
+ if (entry?.state === 'inflight' && entry.requestId === ticket.id) {
517
+ entry.state = outcome === 'retryable' ? 'unused' : outcome
518
+ delete entry.requestId
519
+ }
520
+ }
521
+ if (ticket.operation === 'shared-spawn' && ticket.parentTicketId) {
522
+ const delegate = state.delegates.find((candidate) => candidate.parentTicketId === ticket.parentTicketId
523
+ && candidate.sessionId === ticket.sessionId && candidate.state === 'running')
524
+ if (delegate) delegate.state = 'completed'
525
+ }
526
+ dirty()
527
+ })
528
+
529
+ const delegateForTicket = (ticketId: string, sessionId: string): string => locked((state, dirty) => {
530
+ const ticket = state.tickets.find((candidate) => candidate.id === ticketId && candidate.operation === 'resume'
531
+ && candidate.mode === 'maintenance' && candidate.sessionId === sessionId && candidate.epoch === state.epoch)
532
+ if (!ticket || state.state !== 'active') return fail('maintenance_delegate_invalid', 'resume ticket is not live for this session')
533
+ if (state.delegates.some((delegate) => delegate.parentTicketId === ticket.id))
534
+ fail('maintenance_delegate_invalid', 'resume ticket already delegated its shared spawn')
535
+ const token = input.randomBytes(32).toString('hex')
536
+ state.delegates.push({ tokenHash: hashToken(token), parentTicketId: ticket.id, epoch: state.epoch, operation: 'shared-spawn', sessionId, state: 'unused' })
537
+ dirty()
538
+ return token
539
+ })
540
+
541
+ const readState = (): MaintenanceState => locked((state) => ({
542
+ state: state.state,
543
+ epoch: state.epoch,
544
+ owner: copy(state.owner),
545
+ heartbeatDeadline: state.heartbeatDeadline,
546
+ tickets: copy(state.tickets.map((ticket) => ({
547
+ operation: ticket.operation,
548
+ ...(ticket.sessionId ? { sessionId: ticket.sessionId } : {}),
549
+ ...(ticket.force !== undefined ? { force: ticket.force } : {}),
550
+ owner: ticket.owner,
551
+ deadline: ticket.deadline,
552
+ }))),
553
+ capabilities: copy(state.capabilities),
554
+ }))
555
+
556
+ const acquireLease = async ({ capabilities: rawCapabilities, owner, ttlMs, waitMs }: { capabilities: readonly Capability[]; owner: LeaseOwner; ttlMs: number; waitMs: number }): Promise<MaintenanceLease> => {
557
+ if (!Number.isFinite(ttlMs) || ttlMs < MIN_TTL_MS || ttlMs > MAX_TTL_MS || !Number.isFinite(waitMs) || waitMs < 0 || waitMs > MAX_WAIT_MS || waitMs > ttlMs)
558
+ fail('maintenance_invalid', 'maintenance ttl/wait is outside the finite bounds')
559
+ if (identityState(owner) !== 'live') fail('maintenance_invalid', 'maintenance lease owner identity is not exact and live')
560
+ const capabilities = canonicalCapabilities(rawCapabilities)
561
+ const token = input.randomBytes(32).toString('hex')
562
+ const started = locked((state, dirty) => {
563
+ if (state.state !== 'open') fail('maintenance_conflict', `session maintenance is already ${state.state}`, { state: state.state, epoch: state.epoch })
564
+ state.state = 'draining'
565
+ state.epoch += 1
566
+ state.tokenHash = hashToken(token)
567
+ state.owner = copy(owner)
568
+ state.heartbeatDeadline = input.now() + ttlMs
569
+ state.capabilities = capabilities.map((capability) => ({ capability, state: 'unused' }))
570
+ state.delegates = []
571
+ if (state.tickets.length === 0) state.state = 'active'
572
+ dirty()
573
+ return {
574
+ token,
575
+ epoch: state.epoch,
576
+ state: state.state as MaintenanceLease['state'],
577
+ owner: copy(owner),
578
+ capabilities: copy(capabilities),
579
+ }
580
+ })
581
+ input.onEvent?.({ type: 'maintenance-acquire', state: started.state, epoch: started.epoch })
582
+ if (started.state === 'active' || waitMs === 0) return started
583
+ const deadline = input.now() + waitMs
584
+ for (;;) {
585
+ const current = readState()
586
+ if (current.epoch !== started.epoch || current.state === 'open') fail('maintenance_conflict', 'maintenance acquisition lost its epoch')
587
+ if (current.state === 'active') return { ...started, state: 'active' }
588
+ if (input.now() >= deadline) return started
589
+ await new Promise((resolve) => setTimeout(resolve, 10))
590
+ }
591
+ }
592
+
593
+ const heartbeatLease = async ({ token, epoch, ttlMs }: { token: string; epoch: number; ttlMs: number }): Promise<void> => {
594
+ if (!Number.isFinite(ttlMs) || ttlMs < MIN_TTL_MS || ttlMs > MAX_TTL_MS) fail('maintenance_invalid', 'maintenance heartbeat ttl is outside the finite bounds')
595
+ locked((state, dirty) => {
596
+ if (state.epoch !== epoch || (state.state !== 'active' && state.state !== 'draining')) fail('maintenance_conflict', 'maintenance heartbeat names a stale epoch', { state: state.state, epoch: state.epoch })
597
+ if (!presentedTokenMatches(token, state.tokenHash)) fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch })
598
+ state.heartbeatDeadline = input.now() + ttlMs
599
+ dirty()
600
+ })
601
+ input.onEvent?.({ type: 'maintenance-heartbeat', epoch })
602
+ }
603
+
604
+ const releaseLease = async ({ token, epoch }: { token: string; epoch: number }): Promise<void> => {
605
+ locked((state, dirty) => {
606
+ if (state.epoch !== epoch || state.state === 'open') fail('maintenance_conflict', 'maintenance release names a stale epoch', { state: state.state, epoch: state.epoch })
607
+ if (!presentedTokenMatches(token, state.tokenHash)) fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch })
608
+ if (state.tickets.length) fail('maintenance_tickets_live', 'maintenance cannot release while exact live or ambiguous tickets remain', { state: state.state, epoch: state.epoch })
609
+ state.state = 'open'
610
+ state.tokenHash = null
611
+ state.owner = null
612
+ state.heartbeatDeadline = null
613
+ state.capabilities = []
614
+ state.delegates = []
615
+ dirty()
616
+ })
617
+ input.onEvent?.({ type: 'maintenance-release', epoch })
618
+ }
619
+
620
+ const runOperation = async <T>(operation: Operation, body: (ticket: MaintenanceTicket) => Promise<T> | T): Promise<T> => {
621
+ const owner = input.selfIdentity()
622
+ const ticket = beginOperationTicket(operation, owner)
623
+ let outcome: 'committed' | 'retryable' | 'indeterminate' = 'indeterminate'
624
+ try {
625
+ const result = await context.run({ ticketId: ticket.id }, () => body({
626
+ id: ticket.id,
627
+ epoch: ticket.epoch,
628
+ delegateSharedSpawn: (sessionId: string) => delegateForTicket(ticket.id, sessionId),
629
+ }))
630
+ outcome = (operation.op === 'stop' || operation.op === 'resume')
631
+ && (result === false || (!!result && typeof result === 'object' && 'ok' in result && (result as { ok?: unknown }).ok === false))
632
+ ? 'retryable'
633
+ : 'committed'
634
+ return result
635
+ } finally {
636
+ finishTicket(ticket.id, outcome)
637
+ }
638
+ }
639
+
640
+ const runOperationSync = <T>(operation: Operation, body: (ticket: MaintenanceTicket) => T): T => {
641
+ const owner = input.selfIdentity()
642
+ const ticket = beginOperationTicket(operation, owner)
643
+ let outcome: 'committed' | 'retryable' | 'indeterminate' = 'indeterminate'
644
+ try {
645
+ const result = context.run({ ticketId: ticket.id }, () => body({
646
+ id: ticket.id,
647
+ epoch: ticket.epoch,
648
+ delegateSharedSpawn: (sessionId: string) => delegateForTicket(ticket.id, sessionId),
649
+ }))
650
+ outcome = (operation.op === 'stop' || operation.op === 'resume')
651
+ && (result === false || (!!result && typeof result === 'object' && 'ok' in result && (result as { ok?: unknown }).ok === false))
652
+ ? 'retryable'
653
+ : 'committed'
654
+ return result
655
+ } finally {
656
+ finishTicket(ticket.id, outcome)
657
+ }
658
+ }
659
+
660
+ const authorizeHttpOperation = async ({ authenticated, projectMatches, headers, operation }: { authenticated: boolean; projectMatches: boolean; headers: Record<string, string>; operation: Operation }): Promise<Authorization | undefined> => {
661
+ if (!authenticated) fail('unauthorized', 'authentication is required before maintenance admission')
662
+ if (!projectMatches) fail('project_mismatch', 'request project does not match the maintenance project')
663
+ const token = Object.entries(headers).find(([name]) => name.toLowerCase() === 'x-spexcode-session-maintenance')?.[1] ?? ''
664
+ return locked((state) => {
665
+ if (!token) {
666
+ if (state.state === 'active') fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
667
+ return undefined
668
+ }
669
+ if (state.state !== 'active') fail('maintenance_conflict', 'maintenance lease is not active', { state: state.state, epoch: state.epoch })
670
+ if (!presentedTokenMatches(token, state.tokenHash)) fail('maintenance_token_invalid', 'maintenance bearer is missing, wrong, or stale', { state: state.state, epoch: state.epoch })
671
+ const entry = capabilityFor(state, operation)
672
+ if (!entry) return fail('maintenance_capability_missing', 'operation is not in the exact maintenance capability plan', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
673
+ if (entry.state !== 'unused') fail('maintenance_capability_used', 'maintenance capability is already inflight, committed, or indeterminate', { state: state.state, epoch: state.epoch, operation: operation.op, sessionId: operationSession(operation) })
674
+ return { token, epoch: state.epoch }
675
+ })
676
+ }
677
+
678
+ const beginExternalOperation = (operation: Operation, owner: ProcessIdentity): string => {
679
+ if (identityState(owner) !== 'live') fail('maintenance_invalid', 'external operation owner is not exact and live')
680
+ return beginOperationTicket(operation, owner).id
681
+ }
682
+ const finishExternalOperation = (ticketId: string, owner: ProcessIdentity): void => locked((state, dirty) => {
683
+ const index = state.tickets.findIndex((ticket) => ticket.id === ticketId && sameIdentity(ticket.owner, owner))
684
+ if (index < 0) return
685
+ state.tickets.splice(index, 1)
686
+ dirty()
687
+ })
688
+
689
+ return {
690
+ headerName: 'X-SpexCode-Session-Maintenance',
691
+ runOperation, runOperationSync, acquireLease, heartbeatLease, releaseLease, readState, authorizeHttpOperation,
692
+ beginExternalOperation, finishExternalOperation,
693
+ }
694
+ }
695
+
696
+ function productionProcessIdentity(pid: number): ProcessReading {
697
+ try { process.kill(pid, 0) } catch (error) {
698
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') return null
699
+ return 'ambiguous'
700
+ }
701
+ const startToken = processStartToken(pid)
702
+ return startToken ? { pid, startToken } : 'ambiguous'
703
+ }
704
+
705
+ export function exactProcessIdentity(pid: number): ProcessIdentity | null {
706
+ const reading = productionProcessIdentity(pid)
707
+ return reading && reading !== 'ambiguous' ? reading : null
708
+ }
709
+
710
+ const coordinators = new Map<string, ReturnType<typeof createSessionMaintenance>>()
711
+ export function sessionMaintenance(root = projectRuntimeRoot()): ReturnType<typeof createSessionMaintenance> {
712
+ let coordinator = coordinators.get(root)
713
+ if (coordinator) return coordinator
714
+ const detectedStart = processStartToken(process.pid)
715
+ if (!detectedStart) throw new SessionMaintenanceError('maintenance_identity_unknown', `cannot read exact process identity for ${process.pid}`)
716
+ const selfStart: string = detectedStart
717
+ coordinator = createSessionMaintenance({
718
+ runtimeRoot: root,
719
+ now: () => Date.now(),
720
+ randomBytes: cryptoRandomBytes,
721
+ processIdentity: productionProcessIdentity,
722
+ selfIdentity: () => ({ pid: process.pid, startToken: selfStart }),
723
+ ticketReportMs: 30_000,
724
+ })
725
+ coordinators.set(root, coordinator)
726
+ return coordinator
727
+ }
728
+
729
+ export const runSessionOperation = <T>(operation: Operation, body: (ticket: MaintenanceTicket) => Promise<T> | T): Promise<T> =>
730
+ sessionMaintenance().runOperation(operation, body)
731
+ export const runSessionOperationSync = <T>(operation: Operation, body: (ticket: MaintenanceTicket) => T): T =>
732
+ sessionMaintenance().runOperationSync(operation, body)
733
+
734
+ export function maintenanceBrokerDescriptors(): { request: number; response: number; turn: number } | null {
735
+ const values = (process.env.SPEXCODE_MAINTENANCE_BROKER_FDS || '').split(',').map(Number)
736
+ if (values.length !== 3 || values.some((fd) => !Number.isInteger(fd) || fd < 3) || new Set(values).size !== 3) return null
737
+ return { request: values[0], response: values[1], turn: values[2] }
738
+ }
739
+
740
+ export function maintenanceErrorPayload(error: unknown): Record<string, unknown> | null {
741
+ if (!(error instanceof SessionMaintenanceError)) return null
742
+ return {
743
+ error: error.message,
744
+ code: error.code,
745
+ ...(error.state ? { state: error.state } : {}),
746
+ ...(error.epoch !== undefined ? { epoch: error.epoch } : {}),
747
+ ...(error.operation ? { operation: error.operation } : {}),
748
+ ...(error.sessionId ? { sessionId: error.sessionId } : {}),
749
+ }
750
+ }