spexcode 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +116 -66
- package/spec-cli/src/cli.ts +221 -72
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +965 -70
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { platform } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export type ProcessIdentity = { pid: number; startToken: string }
|
|
7
|
+
export type ProcessTopology = ProcessIdentity & { processGroupId: number; sessionId: number }
|
|
8
|
+
|
|
9
|
+
export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
|
|
10
|
+
const end = text.lastIndexOf(')')
|
|
11
|
+
if (end < 0) throw new Error('malformed proc stat')
|
|
12
|
+
const fields = text.slice(end + 2).trim().split(/\s+/)
|
|
13
|
+
if (fields.length < 22) throw new Error('short proc stat')
|
|
14
|
+
return {
|
|
15
|
+
ppid: Number(fields[1]),
|
|
16
|
+
processGroupId: Number(fields[2]),
|
|
17
|
+
sessionId: Number(fields[3]),
|
|
18
|
+
ticks: Number(fields[11]) + Number(fields[12]),
|
|
19
|
+
startToken: fields[19],
|
|
20
|
+
rssPages: Number(fields[21]),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function processTopology(pid: number, procRoot = '/proc'): ProcessTopology | null {
|
|
25
|
+
if (platform() === 'linux' || procRoot !== '/proc') {
|
|
26
|
+
try {
|
|
27
|
+
const stat = parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8'))
|
|
28
|
+
return { pid, startToken: stat.startToken, processGroupId: stat.processGroupId, sessionId: stat.sessionId }
|
|
29
|
+
} catch { return null }
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const [processGroupId, sessionId] = execFileSync('ps', ['-o', 'pgid=', '-o', 'sess=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/).map(Number)
|
|
33
|
+
const startToken = processStartToken(pid, procRoot)
|
|
34
|
+
return startToken && Number.isFinite(processGroupId) && Number.isFinite(sessionId)
|
|
35
|
+
? { pid, startToken, processGroupId, sessionId }
|
|
36
|
+
: null
|
|
37
|
+
} catch { return null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function processStartToken(pid: number, procRoot = '/proc'): string | null {
|
|
41
|
+
if (platform() === 'linux' || procRoot !== '/proc') {
|
|
42
|
+
try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
|
|
43
|
+
catch { return null }
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
|
|
47
|
+
return started || null
|
|
48
|
+
} catch { return null }
|
|
49
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
// One pure identity seam for every project-scoped runtime consumer. Git discovery stays with callers;
|
|
5
|
+
// once they have the shared common dir, sessions, materialize, history indexes, backends, and uninstall
|
|
6
|
+
// all resolve the same top-level store.
|
|
7
|
+
export function spexcodeHome(): string {
|
|
8
|
+
return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodeProject(root: string): string {
|
|
12
|
+
return root.replace(/[/.]/g, '-')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function projectRuntimeRoot(commonDir: string): string {
|
|
16
|
+
return join(spexcodeHome(), 'projects', encodeProject(dirname(commonDir)))
|
|
17
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
|
-
import { alive } from './sessions.js'
|
|
4
|
+
import { alive, withSessionInputLock } from './sessions.js'
|
|
5
|
+
import { runSessionOperationSync } from './session-maintenance.js'
|
|
5
6
|
|
|
6
7
|
const pexec = promisify(execFile)
|
|
7
8
|
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
@@ -20,6 +21,7 @@ type Subscription = {
|
|
|
20
21
|
bridge?: Bridge
|
|
21
22
|
lingerTimer?: ReturnType<typeof setTimeout>
|
|
22
23
|
restoreTimer?: ReturnType<typeof setTimeout>
|
|
24
|
+
startupError?: string
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
type Bridge = {
|
|
@@ -37,6 +39,7 @@ type Bridge = {
|
|
|
37
39
|
refreshRunning: boolean
|
|
38
40
|
refreshOffset?: number
|
|
39
41
|
deliveryTimer?: ReturnType<typeof setTimeout>
|
|
42
|
+
startupError?: string
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const subscribers = new Map<string, Map<Viewer, Subscription>>()
|
|
@@ -103,6 +106,8 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
|
|
|
103
106
|
const ready = line.match(/^READY (\d+)$/)
|
|
104
107
|
if (ready) {
|
|
105
108
|
bridge.ptyPid = Number(ready[1])
|
|
109
|
+
const subscription = currentSubscription(bridge.id, bridge.viewer)
|
|
110
|
+
if (subscription) subscription.startupError = undefined
|
|
106
111
|
if (bridge.delivery === 'initial') {
|
|
107
112
|
armDeliveryBoundary(bridge)
|
|
108
113
|
queueRefresh(bridge)
|
|
@@ -117,11 +122,28 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
|
|
|
117
122
|
queueRefresh(bridge)
|
|
118
123
|
}
|
|
119
124
|
} else if (line) {
|
|
125
|
+
const failed = line.match(/^ERROR (.+)$/)
|
|
126
|
+
if (failed) reportStartupError(bridge, failed[1])
|
|
120
127
|
console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
|
|
132
|
+
function reportStartupError(bridge: Bridge, detail: string): void {
|
|
133
|
+
const subscription = currentSubscription(bridge.id, bridge.viewer)
|
|
134
|
+
if (!subscription || subscription.bridge !== bridge) return
|
|
135
|
+
const message = boundedStartupError(detail)
|
|
136
|
+
bridge.startupError = message
|
|
137
|
+
if (!message || subscription.startupError === message) return
|
|
138
|
+
subscription.startupError = message
|
|
139
|
+
deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function boundedStartupError(detail: unknown): string {
|
|
143
|
+
return (detail instanceof Error ? detail.message : String(detail))
|
|
144
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start'
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
|
|
126
148
|
if (subscription.bridge) return { bridge: subscription.bridge, created: false }
|
|
127
149
|
let proc: ChildProcessWithoutNullStreams | undefined
|
|
@@ -130,8 +152,13 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
|
|
|
130
152
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
131
153
|
env: process.env,
|
|
132
154
|
})
|
|
133
|
-
} catch {
|
|
155
|
+
} catch (error) {
|
|
134
156
|
try { proc?.kill() } catch { /* spawn did not complete */ }
|
|
157
|
+
const detail = boundedStartupError(error)
|
|
158
|
+
if (subscription.startupError !== detail) {
|
|
159
|
+
subscription.startupError = detail
|
|
160
|
+
try { viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8')) } catch { /* socket closed */ }
|
|
161
|
+
}
|
|
135
162
|
return { bridge: null, created: false }
|
|
136
163
|
}
|
|
137
164
|
const bridge: Bridge = {
|
|
@@ -142,18 +169,19 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
|
|
|
142
169
|
proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
|
|
143
170
|
proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
|
|
144
171
|
let reaped = false
|
|
145
|
-
const gone = () => {
|
|
172
|
+
const gone = (detail?: string) => {
|
|
146
173
|
if (reaped) return
|
|
147
174
|
reaped = true
|
|
148
175
|
const current = currentSubscription(id, viewer)
|
|
149
176
|
if (current?.bridge !== bridge) return
|
|
177
|
+
if (!bridge.ptyPid && !bridge.startupError) reportStartupError(bridge, detail || 'helper exited before native PTY startup')
|
|
150
178
|
current.bridge = undefined
|
|
151
179
|
clearDelivery(bridge)
|
|
152
180
|
try { bridge.proc.kill() } catch { /* already gone */ }
|
|
153
181
|
scheduleRestore(id, viewer, current)
|
|
154
182
|
}
|
|
155
|
-
proc.on('exit', gone)
|
|
156
|
-
proc.on('error', gone)
|
|
183
|
+
proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`))
|
|
184
|
+
proc.on('error', (error) => gone(`helper process failed: ${error.message}`))
|
|
157
185
|
return { bridge, created: true }
|
|
158
186
|
}
|
|
159
187
|
|
|
@@ -335,8 +363,10 @@ const MAX_INPUT_BYTES = 64 * 1024
|
|
|
335
363
|
export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
|
|
336
364
|
const subscription = currentSubscription(id, viewer)
|
|
337
365
|
if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
|
|
338
|
-
|
|
339
|
-
|
|
366
|
+
return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
|
|
367
|
+
sendControl(subscription.bridge!, { t: 'input', data })
|
|
368
|
+
return true
|
|
369
|
+
}) ?? false)
|
|
340
370
|
}
|
|
341
371
|
|
|
342
372
|
async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as pty from 'node-pty'
|
|
2
2
|
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs'
|
|
3
4
|
|
|
4
5
|
const [id, colsArg, rowsArg] = process.argv.slice(2)
|
|
5
6
|
const cols = Number(colsArg)
|
|
@@ -40,12 +41,21 @@ try {
|
|
|
40
41
|
}
|
|
41
42
|
} catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
let terminal
|
|
45
|
+
try {
|
|
46
|
+
ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native))
|
|
47
|
+
terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
|
|
48
|
+
name: 'xterm-256color',
|
|
49
|
+
cols,
|
|
50
|
+
rows,
|
|
51
|
+
env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
|
|
52
|
+
})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const detail = (error instanceof Error ? error.message : String(error))
|
|
55
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500)
|
|
56
|
+
process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
49
59
|
|
|
50
60
|
terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
|
|
51
61
|
terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { chmodSync, statSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
|
|
7
|
+
export function nodePtySpawnHelperPath(nativeModule) {
|
|
8
|
+
const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename
|
|
9
|
+
if (!nativeAddon) throw new Error('cannot locate node-pty loaded native addon')
|
|
10
|
+
return join(dirname(nativeAddon), 'spawn-helper')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ensureExecutableIfPresent(path) {
|
|
14
|
+
let mode
|
|
15
|
+
try {
|
|
16
|
+
mode = statSync(path).mode & 0o777
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error?.code === 'ENOENT') return
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
if ((mode & 0o111) !== 0o111) chmodSync(path, mode | 0o111)
|
|
22
|
+
}
|
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -15,6 +15,11 @@ import { EVAL_QUERY_DEFAULT, ISSUE_QUERY_DEFAULT, readToken } from '../../spec-d
|
|
|
15
15
|
export const REVIEW_PER_PAGE = 25
|
|
16
16
|
|
|
17
17
|
type ReviewItem = Record<string, unknown>
|
|
18
|
+
// A section count is one number, or — when the domain adapter splits that section — its named buckets,
|
|
19
|
+
// which re-add to the same whole population ([[review-filters]] owns the split; Evals splits its measured
|
|
20
|
+
// verdicts into {fresh,stale} so the remeasurement debt travels with the count instead of being recomputed
|
|
21
|
+
// per surface). The fold happens ONCE, here on the server, over the complete filtered population.
|
|
22
|
+
export type ReviewCount = number | Record<string, number>
|
|
18
23
|
type ReviewOption = { value: string; label?: string; count?: number }
|
|
19
24
|
type ReviewFacet = { key: string; label?: string; value: string; meaningful?: boolean; options: ReviewOption[] }
|
|
20
25
|
type EvalNeighbor = { node: string; scenario: string; state: string }
|
|
@@ -29,7 +34,7 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
|
|
|
29
34
|
prev: number | null
|
|
30
35
|
next: number | null
|
|
31
36
|
revision: string
|
|
32
|
-
counts: Record<string,
|
|
37
|
+
counts: Record<string, ReviewCount>
|
|
33
38
|
facets: Record<string, ReviewFacet>
|
|
34
39
|
section: { key: string; value: string; options: ReviewOption[] } | null
|
|
35
40
|
}
|
|
@@ -129,12 +134,12 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
|
|
|
129
134
|
}
|
|
130
135
|
}
|
|
131
136
|
|
|
132
|
-
const byNewest = (a: any, b: any): number =>
|
|
137
|
+
const byNewest = (a: any, b: any): number => Number(b.filterKind === EVAL_FILTER_KIND.RESULT) - Number(a.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
138
|
+
|| String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
133
139
|
|| String(a.node ?? '').localeCompare(String(b.node ?? ''))
|
|
134
140
|
|| String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''))
|
|
135
141
|
|
|
136
142
|
export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
137
|
-
const blind: any[] = []
|
|
138
143
|
const items: any[] = []
|
|
139
144
|
for (const node of nodes ?? []) {
|
|
140
145
|
const latest = new Map<string, any>()
|
|
@@ -142,7 +147,7 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
|
142
147
|
for (const scenario of node.scenarios ?? []) {
|
|
143
148
|
const reading = latest.get(scenario.name)
|
|
144
149
|
if (!reading) {
|
|
145
|
-
|
|
150
|
+
items.push({
|
|
146
151
|
scenario: scenario.name,
|
|
147
152
|
expected: scenario.expected,
|
|
148
153
|
tags: scenario.tags,
|
|
@@ -163,21 +168,18 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
|
|
|
163
168
|
})
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
|
-
|
|
167
|
-
return [...blind, ...items.sort(byNewest)]
|
|
171
|
+
return items.sort(byNewest)
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
171
|
-
const
|
|
172
|
-
const own: any[] = []
|
|
173
|
-
const inherited: any[] = []
|
|
175
|
+
const items: any[] = []
|
|
174
176
|
for (const node of model.nodes ?? []) {
|
|
175
177
|
const latest = new Map<string, any>()
|
|
176
178
|
for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
|
|
177
179
|
for (const scenario of node.scenarios ?? []) {
|
|
178
180
|
const reading = latest.get(scenario.name)
|
|
179
181
|
if (!reading) {
|
|
180
|
-
|
|
182
|
+
items.push({
|
|
181
183
|
scenario: scenario.name,
|
|
182
184
|
expected: scenario.expected,
|
|
183
185
|
tags: scenario.tags,
|
|
@@ -192,15 +194,16 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
|
192
194
|
...reading,
|
|
193
195
|
expected: scenario.expected ?? reading.expected,
|
|
194
196
|
tags: scenario.tags,
|
|
197
|
+
impact: scenario.impact,
|
|
195
198
|
state: evalReviewState(reading),
|
|
196
199
|
node: node.id,
|
|
197
200
|
hue: node.hue,
|
|
198
201
|
filterKind: EVAL_FILTER_KIND.RESULT,
|
|
199
202
|
}
|
|
200
|
-
|
|
203
|
+
items.push(item)
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
|
-
return
|
|
206
|
+
return items.sort(byNewest)
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
@@ -327,9 +330,10 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
327
330
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
328
331
|
summary: model.summary,
|
|
329
332
|
evalRevision: model.evalRevision,
|
|
333
|
+
impact: model.impact,
|
|
330
334
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
331
335
|
domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
|
|
332
|
-
evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
|
|
336
|
+
evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
|
|
333
337
|
}),
|
|
334
338
|
}
|
|
335
339
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { repoRoot } from './git.js'
|
|
6
|
+
import { readJsonConfig, runtimeRoot } from './layout.js'
|
|
7
|
+
import { processStartToken, processTopology, type ProcessIdentity } from './process-identity.js'
|
|
8
|
+
|
|
9
|
+
export type BackendInstanceRecord = {
|
|
10
|
+
version: 1
|
|
11
|
+
instanceId: string
|
|
12
|
+
pid: number
|
|
13
|
+
startToken: string
|
|
14
|
+
projectRoot: string
|
|
15
|
+
startedAt: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const backendInstancesDir = () => join(runtimeRoot(), 'backend-instances')
|
|
19
|
+
const backendInstancePath = (instanceId: string) => join(backendInstancesDir(), `${instanceId}.json`)
|
|
20
|
+
|
|
21
|
+
const atomicJson = (path: string, value: unknown) => {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
23
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
24
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
25
|
+
renameSync(tmp, path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readBackendInstanceRecords(root = repoRoot()): BackendInstanceRecord[] {
|
|
29
|
+
let names: string[]
|
|
30
|
+
try { names = readdirSync(backendInstancesDir()) } catch { return [] }
|
|
31
|
+
return names.flatMap((name) => {
|
|
32
|
+
try {
|
|
33
|
+
const rec = readJsonConfig(join(backendInstancesDir(), name)) as BackendInstanceRecord
|
|
34
|
+
return rec?.version === 1 && rec.projectRoot === root ? [rec] : []
|
|
35
|
+
} catch { return [] }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerBackendInstance(instanceId: string, pid = process.pid, root = repoRoot()): BackendInstanceRecord {
|
|
40
|
+
const startToken = processStartToken(pid)
|
|
41
|
+
if (!startToken) throw new Error(`cannot identify backend supervisor PID ${pid}`)
|
|
42
|
+
const record: BackendInstanceRecord = { version: 1, instanceId, pid, startToken, projectRoot: root, startedAt: new Date().toISOString() }
|
|
43
|
+
atomicJson(backendInstancePath(instanceId), record)
|
|
44
|
+
return record
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function unregisterBackendInstance(instanceId: string, pid = process.pid): void {
|
|
48
|
+
try {
|
|
49
|
+
const record = readJsonConfig(backendInstancePath(instanceId)) as BackendInstanceRecord
|
|
50
|
+
if (record.instanceId === instanceId && record.pid === pid && record.startToken === processStartToken(pid)) rmSync(backendInstancePath(instanceId), { force: true })
|
|
51
|
+
} catch { /* not ours / already removed */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writeIsolationStamp(pid: number, file: string): void {
|
|
55
|
+
const topology = processTopology(pid)
|
|
56
|
+
if (!topology) throw new Error(`cannot identify shared runtime PID ${pid}`)
|
|
57
|
+
if (topology.processGroupId !== pid || topology.sessionId !== pid)
|
|
58
|
+
throw new Error(`shared runtime PID ${pid} is not detached from its launch session (pgrp=${topology.processGroupId}, session=${topology.sessionId})`)
|
|
59
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
60
|
+
writeFileSync(file, `detached-v3 ${pid} ${topology.startToken} ${topology.processGroupId} ${topology.sessionId}\n`, { mode: 0o600 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function spawnDetachedRuntime(opts: {
|
|
64
|
+
cwd: string
|
|
65
|
+
logFile: string
|
|
66
|
+
pidFile: string
|
|
67
|
+
isolationFile: string
|
|
68
|
+
command: string
|
|
69
|
+
args: string[]
|
|
70
|
+
env?: NodeJS.ProcessEnv
|
|
71
|
+
}): ProcessIdentity {
|
|
72
|
+
mkdirSync(dirname(opts.logFile), { recursive: true })
|
|
73
|
+
mkdirSync(dirname(opts.pidFile), { recursive: true })
|
|
74
|
+
const log = openSync(opts.logFile, 'a', 0o600)
|
|
75
|
+
let child: ReturnType<typeof spawn>
|
|
76
|
+
try {
|
|
77
|
+
child = spawn(opts.command, opts.args, {
|
|
78
|
+
cwd: opts.cwd,
|
|
79
|
+
env: opts.env ?? process.env,
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: ['ignore', log, log],
|
|
82
|
+
})
|
|
83
|
+
} finally { closeSync(log) }
|
|
84
|
+
child.once('error', () => {})
|
|
85
|
+
if (!child.pid) throw new Error(`could not spawn detached shared runtime: ${opts.command}`)
|
|
86
|
+
child.unref()
|
|
87
|
+
try {
|
|
88
|
+
writeIsolationStamp(child.pid, opts.isolationFile)
|
|
89
|
+
const startToken = processStartToken(child.pid)
|
|
90
|
+
if (!startToken) throw new Error(`cannot identify detached shared runtime PID ${child.pid}`)
|
|
91
|
+
writeFileSync(opts.pidFile, `${child.pid}\n`, { mode: 0o600 })
|
|
92
|
+
return { pid: child.pid, startToken }
|
|
93
|
+
} catch (error) {
|
|
94
|
+
try { child.kill('SIGTERM') } catch { /* exact just-spawned child already exited */ }
|
|
95
|
+
rmSync(opts.pidFile, { force: true })
|
|
96
|
+
rmSync(opts.isolationFile, { force: true })
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
}
|