spexcode 0.1.5 → 0.2.0
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 +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +60 -19
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/cli.ts
CHANGED
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
export {} // make this a module so top-level await is allowed
|
|
2
2
|
const cmd = process.argv[2]
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
// Registered before any await so a fatal top-level error lands here. Errors we OWN (BackendError, the
|
|
5
|
+
// loud malformed-config ConfigError) are matched BY NAME — to avoid importing them — and rendered as a
|
|
6
|
+
// one-line `spex: <message>` (a user's config typo must read as their typo, not a SpexCode stack dump);
|
|
7
|
+
// anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
|
|
8
|
+
// (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
|
|
9
|
+
// paths route through the same printer.
|
|
10
|
+
function fatal(e: unknown): never {
|
|
11
|
+
if (e instanceof Error && (e.name === 'BackendError' || e.name === 'ConfigError')) console.error(`spex: ${e.message}`)
|
|
7
12
|
else console.error(e)
|
|
8
13
|
process.exit(1)
|
|
9
|
-
}
|
|
14
|
+
}
|
|
15
|
+
process.on('unhandledRejection', fatal)
|
|
16
|
+
process.on('uncaughtException', fatal)
|
|
10
17
|
|
|
11
18
|
// tiny flag reader: --key value (and bare positionals)
|
|
12
19
|
function flag(name: string): string | undefined {
|
|
13
20
|
const i = process.argv.indexOf(`--${name}`)
|
|
14
21
|
return i >= 0 ? process.argv[i + 1] : undefined
|
|
15
22
|
}
|
|
23
|
+
|
|
24
|
+
// Exit AFTER stdout has flushed. process.exit() force-quits without draining buffered pipe writes, so a
|
|
25
|
+
// large piped dump (`spex issues --json | …`, board, review --json) is silently cut off at the pipe
|
|
26
|
+
// buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the returned
|
|
27
|
+
// promise never resolves (process.exit ends the process inside the callback), so `await flushExit(code)`
|
|
28
|
+
// halts execution here exactly like process.exit did — safe to drop in on any unbounded-output verb.
|
|
29
|
+
// EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so we ALSO exit on the
|
|
30
|
+
// stream error rather than hang: the truncation is the reader's choice then, not ours.
|
|
31
|
+
function flushExit(code = 0): Promise<never> {
|
|
32
|
+
return new Promise<never>(() => {
|
|
33
|
+
const done = () => process.exit(code)
|
|
34
|
+
process.stdout.on('error', done)
|
|
35
|
+
process.stdout.write('', done)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
16
38
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
17
39
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
18
|
-
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--harness-session', '--port', '--api-port'])
|
|
40
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api-port', '--preset'])
|
|
19
41
|
function positionals(from: number): string[] {
|
|
20
42
|
const out: string[] = []
|
|
21
43
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -26,6 +48,23 @@ function positionals(from: number): string[] {
|
|
|
26
48
|
return out
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
// After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
|
|
52
|
+
// gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
|
|
53
|
+
// STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
|
|
54
|
+
// agent has an own-session id, a human at a terminal does not.
|
|
55
|
+
async function launchMonitorReminder(id: string): Promise<void> {
|
|
56
|
+
const { ownSessionId } = await import('./sessions.js')
|
|
57
|
+
const agent = ownSessionId()
|
|
58
|
+
console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
|
|
59
|
+
if (agent) {
|
|
60
|
+
// a supervising agent: the per-worker monitor is a backgrounded `spex wait`, which exits on an actionable status.
|
|
61
|
+
console.error(` supervising agent → background \`spex wait ${id}\` (blocks until it hits an actionable status, then exits)`)
|
|
62
|
+
console.error(` or watch the whole stream: \`spex watch\``)
|
|
63
|
+
} else {
|
|
64
|
+
console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
const greeted = new Set<string>()
|
|
30
69
|
async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
|
|
31
70
|
try {
|
|
@@ -76,46 +115,12 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
|
76
115
|
process.exit(2)
|
|
77
116
|
}
|
|
78
117
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Usage: spex <command> [args]
|
|
83
|
-
|
|
84
|
-
Specs / graph
|
|
85
|
-
guide [spec|yatsu] no topic: the setup workflow; spec/yatsu: the file-format manual for authoring nodes
|
|
86
|
-
init [dir] scaffold a repo to adopt SpexCode (seed .spec + install git hooks; default: cwd)
|
|
87
|
-
lint check the spec↔code graph (integrity·living·coverage·drift); when committing, gates on heavy commit-local drift
|
|
88
|
-
ack <node>… --reason stamp Spec-OK on HEAD for one or more nodes (this change keeps their specs valid); --reason required, not stored
|
|
89
|
-
serve run the API server (default :8787). [--port N] sets the listen port (mirrors
|
|
90
|
-
dashboard --api-port, so many projects coexist on one host — cwd picks the project)
|
|
91
|
-
--public --password <pw> expose it on a public IP behind a password + self-signed TLS (no domain
|
|
92
|
-
needed). [--tls-cert F --tls-key F] for your own cert · [--http] to drop TLS
|
|
93
|
-
dashboard serve the dashboard UI on its own port (default 5173), proxying /api to a running
|
|
94
|
-
\`spex serve\`. [--port N] [--api-port N=8787]. The installed replacement for \`npm run web\`.
|
|
95
|
-
board dump the dashboard board state as JSON
|
|
96
|
-
forge <sub> trace a forge's issues/PRs onto spec nodes (read-only): links | eval-pending [--host github] [--node <id>] [--json]
|
|
97
|
-
yatsu <sub> measure a node's scenarios and keep score: scan | eval [.|<node>] [--scenario N] (--pass|--fail|--note T) [--image P|--result P|-] | show [.|<node>] [--json] | clean [--keep-latest|--all]
|
|
98
|
-
hooks <sub> harness-agnostic hook system: compile [--out <file>] (flatten surface:hook nodes into the per-session manifest the dispatcher reads)
|
|
99
|
-
self <sub> diagnose how the workflow reaches THIS self-launched agent: doctor (default) | contract | env
|
|
100
|
-
review <SEL> manager cockpit: review a session (ahead·merge-base diff·gates·proposal) [--json]
|
|
101
|
-
review proof <SEL> render the session's proof of work — self-contained HTML, fully derived (diff·measured yatsu loss·gates) [--open|--out P|--json]
|
|
102
|
-
merge <SEL> manager cockpit: gated atomic merge into main (re-checks gates, then closes) [--keep]
|
|
103
|
-
|
|
104
|
-
Sessions
|
|
105
|
-
ls [SEL…] living-sessions table [--status a,b] [--json]
|
|
106
|
-
watch [SEL…] stream actionable transitions — NEVER EXITS; run it in the BACKGROUND, don't block a turn on it (poll one-shot with \`wait\`) [--as NAME] [--status a,b] [--idle] [--interval N]
|
|
107
|
-
wait <SEL> block until <SEL> is actionable, print it, exit (one-shot — the non-blocking counterpart to watch; draws the graph edge) [--timeout S=1200] [--interval S]
|
|
108
|
-
new "<prompt>" start a session (= session new) [--node X]
|
|
109
|
-
session <sub> new | list | reopen | review | done | merge | exit | close | send | capture | prompt
|
|
110
|
-
session prompt <SEL> print the session's originating prompt (what it was asked to do)
|
|
111
|
-
|
|
112
|
-
SEL = session id (or id-prefix), node, or branch — accepted by every read/control verb (ls·watch·wait·
|
|
113
|
-
review·merge·reopen·exit·close·send·capture·prompt); none (or @all) = every session.`)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// a trailing --help/-h prints the summary and exits BEFORE any verb runs, so a help probe never fires a streaming/mutating command.
|
|
118
|
+
// a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
|
|
119
|
+
// streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
|
|
120
|
+
// of the help journey — see help.ts), falling back to the map for an unknown token.
|
|
117
121
|
if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
|
|
118
|
-
|
|
122
|
+
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
123
|
+
console.log(commandHelp(cmd) ?? overviewHelp())
|
|
119
124
|
process.exit(0)
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -140,10 +145,22 @@ if (cmd === 'serve') {
|
|
|
140
145
|
if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
|
|
141
146
|
serveDashboardLocal({ port, apiPort })
|
|
142
147
|
} else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
143
|
-
|
|
148
|
+
// `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
|
|
149
|
+
const { commandHelp, overviewHelp } = await import('./help.js')
|
|
150
|
+
const topic = positionals(3)[0]
|
|
151
|
+
if (cmd === 'help' && topic) {
|
|
152
|
+
const h = commandHelp(topic)
|
|
153
|
+
if (!h) { console.error(`spex help: no command '${topic}' — run \`spex help\` for the map`); process.exit(2) }
|
|
154
|
+
console.log(h)
|
|
155
|
+
} else console.log(overviewHelp())
|
|
144
156
|
} else if (cmd === 'guide') {
|
|
145
157
|
const { guideText } = await import('./guide.js')
|
|
146
|
-
|
|
158
|
+
const text = guideText(process.argv[3])
|
|
159
|
+
if (text === null) {
|
|
160
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
161
|
+
process.exit(2)
|
|
162
|
+
}
|
|
163
|
+
console.log(text)
|
|
147
164
|
} else if (cmd === 'owner') {
|
|
148
165
|
const { specOwners } = await import('./specs.js')
|
|
149
166
|
const { loadConfig } = await import('./lint.js')
|
|
@@ -200,7 +217,13 @@ if (cmd === 'serve') {
|
|
|
200
217
|
// scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
|
|
201
218
|
// into <targetDir> (default cwd). spex init [targetDir]
|
|
202
219
|
const { specInit } = await import('./init.js')
|
|
203
|
-
await specInit(positionals(3)[0])
|
|
220
|
+
await specInit(positionals(3)[0], flag('preset'))
|
|
221
|
+
} else if (cmd === 'uninstall') {
|
|
222
|
+
// the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
|
|
223
|
+
// .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
|
|
224
|
+
// prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
|
|
225
|
+
const { uninstall } = await import('./uninstall.js')
|
|
226
|
+
uninstall(positionals(3)[0], { hooks: has('hooks') })
|
|
204
227
|
} else if (cmd === 'review' && positionals(3)[0] === 'proof') {
|
|
205
228
|
const sel = positionals(3)[1]
|
|
206
229
|
if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
|
|
@@ -208,7 +231,7 @@ if (cmd === 'serve') {
|
|
|
208
231
|
const { clientProof } = await import('./client.js')
|
|
209
232
|
const r = await clientProof(id, has('json'))
|
|
210
233
|
if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
|
|
211
|
-
if (has('json')) { console.log(r.body);
|
|
234
|
+
if (has('json')) { console.log(r.body); await flushExit(0) }
|
|
212
235
|
const { writeFileSync } = await import('node:fs')
|
|
213
236
|
const { join } = await import('node:path')
|
|
214
237
|
const { tmpdir } = await import('node:os')
|
|
@@ -237,7 +260,6 @@ if (cmd === 'serve') {
|
|
|
237
260
|
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
238
261
|
console.log(' gates:')
|
|
239
262
|
console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
|
|
240
|
-
console.log(` typecheck : ${g.typecheck.ok ? 'ok' : `${g.typecheck.errorCount} error(s)`}`)
|
|
241
263
|
console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
|
|
242
264
|
console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
|
|
243
265
|
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
@@ -254,16 +276,32 @@ if (cmd === 'serve') {
|
|
|
254
276
|
} else if (cmd === 'forge') {
|
|
255
277
|
// thin route — all logic lives in spec-forge.
|
|
256
278
|
const { runForge } = await import('../../spec-forge/src/cli.js')
|
|
257
|
-
|
|
279
|
+
await flushExit(await runForge(process.argv.slice(3)))
|
|
258
280
|
} else if (cmd === 'yatsu') {
|
|
259
281
|
// thin route — all logic lives in spec-yatsu.
|
|
260
282
|
const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
|
|
261
|
-
|
|
262
|
-
} else if (cmd === '
|
|
263
|
-
// @@@
|
|
264
|
-
//
|
|
265
|
-
const {
|
|
266
|
-
|
|
283
|
+
await flushExit(await runYatsu(process.argv.slice(3)))
|
|
284
|
+
} else if (cmd === 'blob') {
|
|
285
|
+
// @@@ blob - the bare evidence-transport verb ([[blob-put]]): put bytes in the shared content-addressed
|
|
286
|
+
// cache and print the hash, decoupled from filing a reading. Thin route — the cache lives in spec-yatsu.
|
|
287
|
+
const { runBlob } = await import('../../spec-yatsu/src/cli.js')
|
|
288
|
+
await flushExit(runBlob(process.argv.slice(3)))
|
|
289
|
+
} else if (cmd === 'issues') {
|
|
290
|
+
// @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
|
|
291
|
+
// store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|sign|
|
|
292
|
+
// resolve|on|off|status, [[local-issues]]) routes to the local store's write verbs, `promote` moves a
|
|
293
|
+
// thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
|
|
294
|
+
// calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
|
|
295
|
+
const { runIssues } = await import('./issues.js')
|
|
296
|
+
await flushExit(await runIssues(process.argv.slice(3)))
|
|
297
|
+
} else if (cmd === 'remark' || cmd === 'resolve' || cmd === 'retract') {
|
|
298
|
+
// @@@ remark - the resolvable interaction primitive ([[remark-substrate]]): pin a concern to a HOST (a
|
|
299
|
+
// local issue, or a scenario `<node> --scenario <name>`) that a second agent can `resolve` and the author
|
|
300
|
+
// can `retract`. CLI-first — the whole author→resolve→retract loop is these thin store-write wrappers, so
|
|
301
|
+
// the dashboard adds no capability. `spex remark <host> --body -|<text> [--code-sha <sha>]`.
|
|
302
|
+
const m = await import('./localIssues.js')
|
|
303
|
+
const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
|
|
304
|
+
await flushExit(await run(process.argv.slice(3)))
|
|
267
305
|
} else if (cmd === 'materialize') {
|
|
268
306
|
// @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
|
|
269
307
|
// shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
|
|
@@ -275,17 +313,18 @@ if (cmd === 'serve') {
|
|
|
275
313
|
// hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
|
|
276
314
|
// the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
|
|
277
315
|
const { runSelf } = await import('./self.js')
|
|
278
|
-
|
|
316
|
+
await flushExit(await runSelf(process.argv.slice(3)))
|
|
279
317
|
} else if (cmd === 'board') {
|
|
280
318
|
const { buildBoard } = await import('./board.js')
|
|
281
319
|
console.log(JSON.stringify(await buildBoard(), null, 2))
|
|
320
|
+
await flushExit(0)
|
|
282
321
|
} else if (cmd === 'search') {
|
|
283
322
|
const { searchSpecs } = await import('./search.js')
|
|
284
323
|
const query = positionals(3).join(' ')
|
|
285
324
|
if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
|
|
286
325
|
const limit = Number(flag('limit')) || 10
|
|
287
326
|
const results = await searchSpecs(query, { limit, onStats: (s) => console.error(`[spec-search] compute ${s.ms.toFixed(1)}ms · ${s.nodes} nodes · ${s.tokens} tokens (excludes process start)`) })
|
|
288
|
-
if (has('json')) { console.log(JSON.stringify(results));
|
|
327
|
+
if (has('json')) { console.log(JSON.stringify(results)); await flushExit(0) }
|
|
289
328
|
if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
290
329
|
results.forEach((r, i) => {
|
|
291
330
|
console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
|
|
@@ -293,21 +332,7 @@ if (cmd === 'serve') {
|
|
|
293
332
|
if (r.snippet) console.log(` ${r.snippet}`)
|
|
294
333
|
})
|
|
295
334
|
process.exit(0)
|
|
296
|
-
} else if (cmd === '
|
|
297
|
-
const { relaySearch } = await import('./relay.js')
|
|
298
|
-
const query = positionals(3).join(' ')
|
|
299
|
-
if (!query.trim()) { console.error('usage: spex relay <query> [--json] [--limit N]'); process.exit(2) }
|
|
300
|
-
const limit = Number(flag('limit')) || 3
|
|
301
|
-
const hits = await relaySearch(query, { limit })
|
|
302
|
-
if (has('json')) { console.log(JSON.stringify(hits)); process.exit(0) }
|
|
303
|
-
if (!hits.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
|
|
304
|
-
hits.forEach((h, i) => {
|
|
305
|
-
console.log(`${String(i + 1).padStart(2)}. ${h.title} [${h.id}] · score ${h.score}`)
|
|
306
|
-
if (h.code.length) h.code.forEach((c) => console.log(` ${c}`))
|
|
307
|
-
else console.log(` (no governed code: files — a pure-prose node)`)
|
|
308
|
-
})
|
|
309
|
-
process.exit(0)
|
|
310
|
-
} else if (cmd === 'ls' || cmd === 'sessions') {
|
|
335
|
+
} else if (cmd === 'ls') {
|
|
311
336
|
// pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
|
|
312
337
|
// the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
313
338
|
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
@@ -353,7 +378,9 @@ if (cmd === 'serve') {
|
|
|
353
378
|
// it falls back to an in-process launch only when no backend answers.
|
|
354
379
|
const { createSession } = await import('./sessions.js')
|
|
355
380
|
const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
|
|
356
|
-
|
|
381
|
+
const created = await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined, flag('launcher') ?? undefined)
|
|
382
|
+
console.log(JSON.stringify(created, null, 2))
|
|
383
|
+
await launchMonitorReminder(created.id)
|
|
357
384
|
} else if (cmd === 'session') {
|
|
358
385
|
const sub = process.argv[3]
|
|
359
386
|
// `s` (sessions.ts) backs the state PRODUCERS that stay local (state/done/park/fail/ask/idle write the
|
|
@@ -368,19 +395,24 @@ if (cmd === 'serve') {
|
|
|
368
395
|
const sess = flag('session')
|
|
369
396
|
// appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
|
|
370
397
|
const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
|
|
398
|
+
// appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
|
|
399
|
+
const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
|
|
371
400
|
if (sub === 'new') {
|
|
372
401
|
// route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
|
|
373
402
|
// prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
|
|
374
403
|
// SAME as the `spex new "<prompt>"` shorthand — one prompt-resolution rule, not two.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
} else if (sub === 'reopen'
|
|
379
|
-
//
|
|
404
|
+
const created = await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined, flag('launcher') ?? undefined)
|
|
405
|
+
console.log(JSON.stringify(created, null, 2))
|
|
406
|
+
await launchMonitorReminder(created.id)
|
|
407
|
+
} else if (sub === 'reopen') {
|
|
408
|
+
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
409
|
+
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
|
|
410
|
+
// RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
|
|
411
|
+
// overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
|
|
380
412
|
const full = await resolveSelectorOrExit(id)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
console.
|
|
413
|
+
const r = await c.clientReopen(full, process.argv.includes('--force'))
|
|
414
|
+
if (r.ok) console.log(`${full} -> reopened`)
|
|
415
|
+
else { console.error(`spex session reopen: ${r.error || `no such session ${full}`}`); process.exit(2) }
|
|
384
416
|
} else if (sub === 'state') {
|
|
385
417
|
// the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
|
|
386
418
|
const st = process.argv[4] as any
|
|
@@ -389,7 +421,8 @@ if (cmd === 'serve') {
|
|
|
389
421
|
} else if (sub === 'done') {
|
|
390
422
|
// sugar for awaiting; --propose merge|nothing|close, optional --note
|
|
391
423
|
const p = (flag('propose') as any) || 'nothing'
|
|
392
|
-
|
|
424
|
+
const closeNote = p === 'close' ? CLOSE_CLEANUP : ''
|
|
425
|
+
console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
|
|
393
426
|
} else if (sub === 'park') {
|
|
394
427
|
// sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
|
|
395
428
|
console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
|
|
@@ -415,15 +448,6 @@ if (cmd === 'serve') {
|
|
|
415
448
|
// never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `ask`
|
|
416
449
|
// (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
|
|
417
450
|
console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
|
|
418
|
-
} else if (sub === 'merge') {
|
|
419
|
-
// merge dispatch (same as top-level `spex merge`): reopen the session and hand its OWN agent the merge
|
|
420
|
-
// prompt — the agent runs the --no-ff merge, resolves conflicts, verifies main advanced, and proposes
|
|
421
|
-
// close. The SERVER never touches main. Fail-loud: an unreachable agent prints the reason, exits non-zero.
|
|
422
|
-
const full = await resolveSelectorOrExit(id)
|
|
423
|
-
const r = await c.clientMerge(full)
|
|
424
|
-
if (r.dispatched) console.log(`merge dispatched to ${full} — its agent is landing the merge`)
|
|
425
|
-
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
426
|
-
process.exit(r.dispatched ? 0 : 1)
|
|
427
451
|
} else if (sub === 'exit') {
|
|
428
452
|
// the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
|
|
429
453
|
// can be resumed (reopen/relaunch). Distinct from `close`, which removes the worktree.
|
|
@@ -466,35 +490,64 @@ if (cmd === 'serve') {
|
|
|
466
490
|
if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
|
|
467
491
|
process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
|
|
468
492
|
} else {
|
|
469
|
-
console.error('spex session: new|
|
|
493
|
+
console.error('spex session: new|reopen|done|park|ask|idle|exit|close|send|capture|prompt'); process.exit(2)
|
|
470
494
|
}
|
|
471
|
-
} else if (cmd === '
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
495
|
+
} else if (cmd === 'internal') {
|
|
496
|
+
// @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
|
|
497
|
+
// kept OUT of the porcelain top level so `spex help`'s vocabulary is exactly what a human/agent types.
|
|
498
|
+
const sub = process.argv[3]
|
|
499
|
+
if (sub === 'trunk') {
|
|
500
|
+
// print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
|
|
501
|
+
// checkout's current branch → 'main'). The pre-commit main-guard captures this so it blocks direct
|
|
502
|
+
// commits on whatever the repo's trunk is actually named, never a hardcoded 'main'. One value, one
|
|
503
|
+
// line; GET /api/layout exposes the same resolution.
|
|
504
|
+
const { mainBranch } = await import('./layout.js')
|
|
505
|
+
console.log(mainBranch())
|
|
506
|
+
} else if (sub === 'codex-launch') {
|
|
507
|
+
// BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
|
|
508
|
+
// (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
|
|
509
|
+
// SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
|
|
510
|
+
// thread id. The launch script then `resume`s it in the visible TUI.
|
|
511
|
+
const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust } = await import('./harness.js')
|
|
512
|
+
const { markHarnessSessionId } = await import('./sessions.js')
|
|
513
|
+
const sock = process.argv[4], cwd = process.argv[5]
|
|
514
|
+
const prompt = process.argv.slice(6).join(' ')
|
|
515
|
+
if (!sock || !cwd) { console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
|
|
516
|
+
// On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
|
|
517
|
+
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
518
|
+
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
519
|
+
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
|
|
520
|
+
const r = await codexStartThread(sock, cwd, bypassHookTrust)
|
|
521
|
+
if (!r.ok) { console.error(r.error); process.exit(1) }
|
|
522
|
+
if (prompt) {
|
|
523
|
+
const t = await codexTurn(sock, r.threadId, prompt, cwd)
|
|
524
|
+
if (!t.ok) { console.error(t.error); process.exit(1) }
|
|
525
|
+
// The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
|
|
526
|
+
// but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
|
|
527
|
+
// lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
|
|
528
|
+
// non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
|
|
529
|
+
// fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
|
|
530
|
+
if (!await waitForCodexRollout(r.threadId, 20000)) {
|
|
531
|
+
console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`)
|
|
532
|
+
process.exit(1)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const sid = process.env.SPEXCODE_SESSION_ID
|
|
536
|
+
if (sid) markHarnessSessionId(sid, r.threadId)
|
|
537
|
+
console.log(r.threadId)
|
|
538
|
+
} else if (sub === 'codex-turn') {
|
|
539
|
+
// fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
|
|
540
|
+
// tests / scripts). steer-vs-start is chosen live from the thread read.
|
|
541
|
+
const { codexTurn } = await import('./harness.js')
|
|
542
|
+
const sock = process.argv[4], tid = process.argv[5], text = process.argv.slice(6).join(' ')
|
|
543
|
+
if (!sock || !tid || !text) { console.error('usage: spex internal codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
544
|
+
const r = await codexTurn(sock, tid, text)
|
|
545
|
+
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
546
|
+
} else {
|
|
547
|
+
const { commandHelp } = await import('./help.js')
|
|
548
|
+
console.error(commandHelp('internal'))
|
|
549
|
+
process.exit(2)
|
|
488
550
|
}
|
|
489
|
-
console.log(r.threadId)
|
|
490
|
-
} else if (cmd === 'codex-turn') {
|
|
491
|
-
// fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
|
|
492
|
-
// tests / scripts). steer-vs-start is chosen live from the thread read.
|
|
493
|
-
const { codexTurn } = await import('./harness.js')
|
|
494
|
-
const sock = process.argv[3], tid = process.argv[4], text = process.argv.slice(5).join(' ')
|
|
495
|
-
if (!sock || !tid || !text) { console.error('usage: spex codex-turn <sock> <threadId> <text...>'); process.exit(2) }
|
|
496
|
-
const r = await codexTurn(sock, tid, text)
|
|
497
|
-
if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
|
|
498
551
|
} else {
|
|
499
552
|
console.error(`spex: unknown command '${cmd}' (try: spex help)`)
|
|
500
553
|
process.exit(2)
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -74,10 +74,12 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
|
|
|
74
74
|
return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// POST /api/sessions/:id/resume —
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
// POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
|
|
78
|
+
// working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
|
|
79
|
+
// `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
|
|
80
|
+
export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
81
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
|
|
82
|
+
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
|
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -8,11 +8,13 @@ import https from 'node:https'
|
|
|
8
8
|
import net from 'node:net'
|
|
9
9
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
10
10
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
11
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs'
|
|
12
|
+
import { gzipSync, createGzip } from 'node:zlib'
|
|
12
13
|
import { join, normalize, extname } from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
14
15
|
import { homedir } from 'node:os'
|
|
15
16
|
import { loginPage } from './login-page.js'
|
|
17
|
+
import { listenOrExit } from './listen.js'
|
|
16
18
|
|
|
17
19
|
// @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
|
|
18
20
|
// chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
|
|
@@ -99,8 +101,8 @@ function cookieOf(header: string | undefined, name: string): string | null {
|
|
|
99
101
|
}
|
|
100
102
|
return null
|
|
101
103
|
}
|
|
102
|
-
function isAuthed(req: http.IncomingMessage, token: string): boolean {
|
|
103
|
-
const c = cookieOf(req.headers.cookie,
|
|
104
|
+
function isAuthed(req: http.IncomingMessage, token: string, cookieName: string): boolean {
|
|
105
|
+
const c = cookieOf(req.headers.cookie, cookieName)
|
|
104
106
|
return c != null && constEq(c, token)
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -117,14 +119,19 @@ export function resolveDistDir(): string {
|
|
|
117
119
|
return join(pkgRoot, '..', 'spec-dashboard', 'dist')
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string }
|
|
122
|
+
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string; onBindFail?: () => void }
|
|
121
123
|
|
|
122
124
|
export function startGateway(opts: GatewayOpts): void {
|
|
123
125
|
// gated ONLY when a password is set; otherwise the login layer doesn't exist and the dashboard is served open.
|
|
124
126
|
const gated = !!opts.password
|
|
125
127
|
const token = gated ? authToken(opts.password) : ''
|
|
126
128
|
const secure = !!opts.tls
|
|
127
|
-
|
|
129
|
+
// the auth cookie is HOST-scoped (RFC 6265 ignores the port), so two gateways on one IP would share a
|
|
130
|
+
// single 'spex_auth' jar entry and clobber each other's login. Key the name by the public port — the
|
|
131
|
+
// unique discriminator on a host, exactly what the user's two URLs differ by — so same-host instances
|
|
132
|
+
// (e.g. :8787 and :8788) stay logged in concurrently and a logout clears only its own.
|
|
133
|
+
const cookieName = `${COOKIE}_${opts.publicPort}`
|
|
134
|
+
const setCookie = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
|
|
128
135
|
|
|
129
136
|
const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
130
137
|
const url = (req.url || '/').split('?')[0]
|
|
@@ -132,23 +139,31 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
132
139
|
// login surface — the only routes reachable without a cookie. Absent entirely when ungated.
|
|
133
140
|
if (url === '/login' && req.method === 'POST') return doLogin(req, res, opts.password, setCookie)
|
|
134
141
|
if (url === '/login') return sendHtml(res, 200, loginPage())
|
|
135
|
-
if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${
|
|
136
|
-
if (!isAuthed(req, token)) {
|
|
142
|
+
if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${cookieName}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
|
|
143
|
+
if (!isAuthed(req, token, cookieName)) {
|
|
137
144
|
if (url.startsWith('/api')) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end('{"error":"authentication required"}') }
|
|
138
145
|
res.writeHead(302, { Location: '/login' }); return res.end()
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
if (url.startsWith('/api')) return proxyHttp(req, res, opts.upstreamPort)
|
|
142
|
-
return serveStatic(res, opts.distDir, url)
|
|
149
|
+
return serveStatic(req, res, opts.distDir, url)
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
|
|
152
|
+
// server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
|
|
153
|
+
// public server in public mode, so it carries the SAME reaping as the child: an abandoned/slow connection
|
|
154
|
+
// dies here instead of piling up. Idle keep-alive / slow-header / never-completing request only; the gated
|
|
155
|
+
// WS upgrade (handled below) is an active connection, not reaped by these. Set AT CONSTRUCTION so the
|
|
156
|
+
// connection-checking sweep is armed with our cadence (a post-hoc set leaves it under-effective).
|
|
157
|
+
const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
|
|
158
|
+
const server = secure
|
|
159
|
+
? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
|
|
160
|
+
: http.createServer(reap, handler)
|
|
146
161
|
|
|
147
162
|
// @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
|
|
148
163
|
// it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
|
|
149
164
|
// upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
|
|
150
165
|
server.on('upgrade', (req, socket, head) => {
|
|
151
|
-
if (gated && !isAuthed(req, token)) { socket.destroy(); return }
|
|
166
|
+
if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
|
|
152
167
|
const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
|
|
153
168
|
up.write(`${req.method} ${req.url} HTTP/1.1\r\n` + rawHeaders(req))
|
|
154
169
|
if (head && head.length) up.write(head)
|
|
@@ -168,8 +183,9 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
168
183
|
console.log(`[gateway] ${label} on ${scheme}://${isLocal ? 'localhost' : '0.0.0.0'}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
|
|
169
184
|
if (!secure && !isLocal) console.log('[gateway] (TLS off — --http)')
|
|
170
185
|
}
|
|
171
|
-
|
|
172
|
-
|
|
186
|
+
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
187
|
+
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
|
|
188
|
+
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
|
|
173
189
|
}
|
|
174
190
|
|
|
175
191
|
function rawHeaders(req: http.IncomingMessage): string {
|
|
@@ -189,26 +205,51 @@ function doLogin(req: http.IncomingMessage, res: http.ServerResponse, password:
|
|
|
189
205
|
})
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
//
|
|
208
|
+
// @@@ gzip at the gateway - compression is TRANSPORT, so it lives here, once, for every deployment — the
|
|
209
|
+
// loopback upstream and the product semantics never know it exists. Text-ish payloads only; three
|
|
210
|
+
// structural exclusions, each load-bearing: an SSE stream must not sit in a zlib buffer (event latency),
|
|
211
|
+
// an already-encoded response is not re-encoded, and binary media (video/image evidence) gains nothing
|
|
212
|
+
// and would fight Range requests.
|
|
213
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/
|
|
214
|
+
const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))
|
|
215
|
+
|
|
216
|
+
// reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
|
|
217
|
+
// stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
|
|
193
218
|
function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number) {
|
|
194
219
|
const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (upRes) => {
|
|
195
|
-
|
|
196
|
-
upRes.
|
|
220
|
+
const type = String(upRes.headers['content-type'] || '')
|
|
221
|
+
const skip = !wantsGzip(req) || upRes.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
|
|
222
|
+
if (skip) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); return }
|
|
223
|
+
const headers = { ...upRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
|
|
224
|
+
delete headers['content-length'] // streamed; the encoded length isn't knowable up front
|
|
225
|
+
res.writeHead(upRes.statusCode || 502, headers)
|
|
226
|
+
upRes.pipe(createGzip()).pipe(res)
|
|
197
227
|
})
|
|
198
228
|
up.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('upstream unreachable') })
|
|
199
229
|
req.pipe(up)
|
|
200
230
|
}
|
|
201
231
|
|
|
202
232
|
// serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
|
|
203
|
-
// traversal is blocked by normalising and confining to distDir.
|
|
204
|
-
|
|
233
|
+
// traversal is blocked by normalising and confining to distDir. Compressible files ship gzipped, memoized
|
|
234
|
+
// per (path, mtime) — a dist file is immutable per build, so each is compressed once, not per request.
|
|
235
|
+
const gzMemo = new Map<string, { mtime: number; gz: Buffer }>()
|
|
236
|
+
function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDir: string, urlPath: string) {
|
|
205
237
|
const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
|
|
206
238
|
let file = join(distDir, rel)
|
|
207
239
|
if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
|
|
208
240
|
if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
|
|
209
241
|
if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
|
|
210
|
-
|
|
211
|
-
|
|
242
|
+
const type = MIME[extname(file)] || 'application/octet-stream'
|
|
243
|
+
const raw = readFileSync(file)
|
|
244
|
+
if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
|
|
245
|
+
const mtime = statSync(file).mtimeMs
|
|
246
|
+
let hit = gzMemo.get(file)
|
|
247
|
+
if (!hit || hit.mtime !== mtime) { hit = { mtime, gz: gzipSync(raw) }; gzMemo.set(file, hit) }
|
|
248
|
+
res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' })
|
|
249
|
+
return res.end(hit.gz)
|
|
250
|
+
}
|
|
251
|
+
res.writeHead(200, { 'Content-Type': type })
|
|
252
|
+
res.end(raw)
|
|
212
253
|
}
|
|
213
254
|
|
|
214
255
|
function sendHtml(res: http.ServerResponse, status: number, html: string) {
|