spexcode 0.6.1 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
package/spec-cli/src/cli.ts
CHANGED
|
@@ -57,7 +57,7 @@ function flushExit(code = 0): Promise<never> {
|
|
|
57
57
|
}
|
|
58
58
|
const has = (name: string) => process.argv.includes(`--${name}`)
|
|
59
59
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
60
|
-
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch'])
|
|
60
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base'])
|
|
61
61
|
function positionals(from: number): string[] {
|
|
62
62
|
const out: string[] = []
|
|
63
63
|
for (let i = from; i < process.argv.length; i++) {
|
|
@@ -81,6 +81,51 @@ function rejectUnknownFlags(command: string, from: number, allowed: readonly str
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
type SessionSendArgs =
|
|
85
|
+
| { selector: string; kind: 'text'; text: string }
|
|
86
|
+
| { selector: string; kind: 'keys'; keys: string[] }
|
|
87
|
+
|
|
88
|
+
function sessionSendUsage(detail: string, keys = false): never {
|
|
89
|
+
console.error(`spex session send: ${detail}`)
|
|
90
|
+
console.error(keys
|
|
91
|
+
? 'usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'
|
|
92
|
+
: 'usage: spex session send <SEL> "<msg>" [--api <url> | --port <n>]\n spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>')
|
|
93
|
+
process.exit(2)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseSessionSendArgs(args: string[]): SessionSendArgs {
|
|
97
|
+
const valueFlags = new Set(['--api', '--port', '--keys', '--password'])
|
|
98
|
+
const bareFlags = new Set(['--insecure'])
|
|
99
|
+
const values = new Map<string, string>()
|
|
100
|
+
const positionals: string[] = []
|
|
101
|
+
let endOfOptions = false
|
|
102
|
+
for (let i = 0; i < args.length; i++) {
|
|
103
|
+
const token = args[i]
|
|
104
|
+
if (!endOfOptions && token === '--') { endOfOptions = true; continue }
|
|
105
|
+
if (!endOfOptions && token.startsWith('--')) {
|
|
106
|
+
if (!valueFlags.has(token) && !bareFlags.has(token)) sessionSendUsage(`unknown flag ${token}`)
|
|
107
|
+
if (values.has(token)) sessionSendUsage(`${token} may appear only once`, token === '--keys')
|
|
108
|
+
values.set(token, '')
|
|
109
|
+
if (bareFlags.has(token)) continue
|
|
110
|
+
const value = args[++i]
|
|
111
|
+
if (value === undefined || value === '' || value.startsWith('--')) sessionSendUsage(`${token} expects one non-empty value`, token === '--keys')
|
|
112
|
+
values.set(token, value)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
positionals.push(token)
|
|
116
|
+
}
|
|
117
|
+
if (values.has('--api') && values.has('--port')) sessionSendUsage('--api and --port are alternate routes; choose one')
|
|
118
|
+
const rawKeys = values.get('--keys')
|
|
119
|
+
if (rawKeys !== undefined) {
|
|
120
|
+
if (positionals.length !== 1) sessionSendUsage('raw keys take one selector and no text message', true)
|
|
121
|
+
const keys = rawKeys.split(/\s+/).filter(Boolean)
|
|
122
|
+
if (!keys.length) sessionSendUsage('--keys expects one non-empty value', true)
|
|
123
|
+
return { selector: positionals[0], kind: 'keys', keys }
|
|
124
|
+
}
|
|
125
|
+
if (positionals.length !== 2) sessionSendUsage('plain send requires exactly one selector and one message')
|
|
126
|
+
return { selector: positionals[0], kind: 'text', text: positionals[1] }
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
const SIGNPOSTS: Record<string, string> = {
|
|
85
130
|
search: 'spex spec search <query>',
|
|
86
131
|
owner: 'spex spec owner <path>',
|
|
@@ -170,18 +215,22 @@ async function localWatchTargetsOrExit(selectors: string[], verb: string): Promi
|
|
|
170
215
|
return targets
|
|
171
216
|
}
|
|
172
217
|
|
|
173
|
-
async function
|
|
218
|
+
async function resolveSessionOrExit(selector: string): Promise<import('./sessions.js').Session> {
|
|
174
219
|
if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
|
|
175
220
|
const { resolveClientSession } = await import('./client.js')
|
|
176
|
-
const {
|
|
221
|
+
const { sessionTitle } = await import('./sessions.js')
|
|
177
222
|
const r = await resolveClientSession(selector)
|
|
178
|
-
if ('ok' in r) return r.ok
|
|
223
|
+
if ('ok' in r) return r.ok
|
|
179
224
|
if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
|
|
180
225
|
console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
|
|
181
|
-
for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${
|
|
226
|
+
for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionTitle(s)}`)
|
|
182
227
|
process.exit(2)
|
|
183
228
|
}
|
|
184
229
|
|
|
230
|
+
async function resolveSelectorOrExit(selector: string): Promise<string> {
|
|
231
|
+
return (await resolveSessionOrExit(selector)).id
|
|
232
|
+
}
|
|
233
|
+
|
|
185
234
|
// the [[session-eval]] EXPORT artifact behind `spex eval ls --session <SEL> --export`: fetch the
|
|
186
235
|
// backend-rendered self-contained HTML, write it (--out, else a tmp file) or open it (--open). Never returns.
|
|
187
236
|
async function evalExport(id: string): Promise<never> {
|
|
@@ -337,11 +386,11 @@ if (cmd === 'serve') {
|
|
|
337
386
|
console.log(h)
|
|
338
387
|
} else console.log(overviewHelp())
|
|
339
388
|
} else if (cmd === 'guide') {
|
|
340
|
-
const { guideText } = await import('./guide.js')
|
|
389
|
+
const { guideText, guideTopics } = await import('./guide.js')
|
|
341
390
|
if (process.argv[3] === 'config') signpost('spex guide config', 'spex guide settings')
|
|
342
391
|
const text = guideText(process.argv[3])
|
|
343
392
|
if (text === null) {
|
|
344
|
-
console.error(`spex guide: no topic '${process.argv[3]}'. Topics:
|
|
393
|
+
console.error(`spex guide: no topic '${process.argv[3]}'. Topics: ${guideTopics().join(', ')}. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
|
|
345
394
|
process.exit(2)
|
|
346
395
|
}
|
|
347
396
|
console.log(text)
|
|
@@ -425,9 +474,25 @@ if (cmd === 'serve') {
|
|
|
425
474
|
console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
|
|
426
475
|
} else if (owners.length === 0) {
|
|
427
476
|
// related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
|
|
428
|
-
// but a human asking gets the honest nuance
|
|
477
|
+
// but a human asking gets the honest nuance about what does and does not track this file.
|
|
429
478
|
if (has('actionable')) process.exit(0)
|
|
430
|
-
|
|
479
|
+
// @@@ both axes - "nothing tracks its drift" is a verdict about the WHOLE tracking model
|
|
480
|
+
// ([[governed-related]]: spec nodes AND eval scenarios), so it cannot be spoken from the spec axis
|
|
481
|
+
// alone — a scenario's code: anchor drives eval freshness with no spec claim involved. Derived, never
|
|
482
|
+
// asserted: a message that enumerates one axis cannot report what the other holds. The empty
|
|
483
|
+
// nodeCode fallback is exact HERE and only here — reaching this branch means no spec node code:-claims
|
|
484
|
+
// the file, so no scenario can anchor to it by inheriting its node's claim, only by an explicit one.
|
|
485
|
+
const { evalNodes, scenarioCodeAxis } = await import('../../spec-eval/src/scenarios.js')
|
|
486
|
+
const anchored: string[] = []
|
|
487
|
+
for (const n of evalNodes(process.cwd())) {
|
|
488
|
+
for (const sc of n.scenarios) {
|
|
489
|
+
if (scenarioCodeAxis(sc.code).paths.includes(rel)) anchored.push(`'${n.id}' scenario '${sc.name}'`)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const tracking = anchored.length
|
|
493
|
+
? `Its drift is tracked on the eval axis only: ${anchored.length} scenario${anchored.length === 1 ? '' : 's'} anchor${anchored.length === 1 ? 's' : ''} freshness to it (${anchored.join(', ')}), so changing it makes those readings stale — but no spec body says what it should do`
|
|
494
|
+
: 'Nothing tracks its drift'
|
|
495
|
+
console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). ${tracking}; if your change is substantive, consider giving it a governing home.`)
|
|
431
496
|
} else if (whole.length <= maxOwners) {
|
|
432
497
|
// a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
|
|
433
498
|
// silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
|
|
@@ -440,16 +505,21 @@ if (cmd === 'serve') {
|
|
|
440
505
|
console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
|
|
441
506
|
}
|
|
442
507
|
} else if (sub === 'lint') {
|
|
443
|
-
const {
|
|
508
|
+
const { specLintReport, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
|
|
444
509
|
const pending = flag('pending')
|
|
445
510
|
if (pending && process.env.SPEXCODE_GATE_SCOPE_ONLY === '1') {
|
|
446
511
|
const touches = await pendingTouchesGoverned(process.cwd(), pending)
|
|
447
512
|
if (!touches) process.exit(76)
|
|
448
513
|
}
|
|
449
|
-
const
|
|
514
|
+
const report = await specLintReport(undefined, undefined, {
|
|
450
515
|
tip: pending || 'HEAD',
|
|
451
516
|
})
|
|
517
|
+
const { findings } = report
|
|
452
518
|
const errors = findings.filter((f) => f.level === 'error')
|
|
519
|
+
if (has('json')) {
|
|
520
|
+
console.log(JSON.stringify(report, null, 2))
|
|
521
|
+
await flushExit(errors.length ? 1 : 0)
|
|
522
|
+
}
|
|
453
523
|
for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
|
|
454
524
|
console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
|
|
455
525
|
// drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
|
|
@@ -513,7 +583,7 @@ if (cmd === 'serve') {
|
|
|
513
583
|
const m = r.model
|
|
514
584
|
const own = m.items.filter((item) => item.inSession).length
|
|
515
585
|
console.log(`eval session [${m.id}]`)
|
|
516
|
-
console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
586
|
+
if (m.gates.length) console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
|
|
517
587
|
if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = evals filed by other sessions)`)
|
|
518
588
|
if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
|
|
519
589
|
let lastNode: string | null = null
|
|
@@ -597,7 +667,7 @@ if (cmd === 'serve') {
|
|
|
597
667
|
console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
|
|
598
668
|
process.exit(2)
|
|
599
669
|
}
|
|
600
|
-
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
|
|
670
|
+
rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'api', 'port'])
|
|
601
671
|
const { createSession } = await import('./sessions.js')
|
|
602
672
|
const promptFile = flag('prompt-file')
|
|
603
673
|
const inline = flag('prompt') ?? positionals(4)[0]
|
|
@@ -610,7 +680,7 @@ if (cmd === 'serve') {
|
|
|
610
680
|
catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
|
|
611
681
|
if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
|
|
612
682
|
}
|
|
613
|
-
const created = await createSession(prompt, flag('launcher') ?? undefined)
|
|
683
|
+
const created = await createSession(prompt, flag('launcher') ?? undefined, flag('name') ?? undefined, flag('base') ?? undefined)
|
|
614
684
|
const { ownSessionId, subscribeSessionWatch } = await import('./sessions.js')
|
|
615
685
|
let watchEstablished = false
|
|
616
686
|
if (created.parent && created.parent === ownSessionId()) {
|
|
@@ -642,6 +712,60 @@ if (cmd === 'serve') {
|
|
|
642
712
|
const report = await clientResources()
|
|
643
713
|
if (has('json')) console.log(JSON.stringify(report, null, 2))
|
|
644
714
|
else console.log((await import('./host-resources.js')).formatResourceReport(report))
|
|
715
|
+
} else if (sub === 'files') {
|
|
716
|
+
rejectUnknownFlags('spex session files', 4, [])
|
|
717
|
+
const [verb, path, extra] = positionals(4)
|
|
718
|
+
if (extra || !verb || (verb !== 'ls' && !path) || (verb === 'ls' && path)) {
|
|
719
|
+
console.error('usage: spex session files add <path> | ls | retract <path>')
|
|
720
|
+
process.exit(2)
|
|
721
|
+
}
|
|
722
|
+
const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
|
|
723
|
+
const id = ownSessionId()
|
|
724
|
+
if (!id) {
|
|
725
|
+
console.error('spex session files: no governed caller session — run this from the agent session that produced the file')
|
|
726
|
+
process.exit(2)
|
|
727
|
+
}
|
|
728
|
+
const files = await import('./session-files.js')
|
|
729
|
+
if (verb === 'ls') {
|
|
730
|
+
for (const file of files.listSessionFiles(id)) console.log(file)
|
|
731
|
+
} else if (verb === 'add') {
|
|
732
|
+
const result = files.addSessionFile(id, path!, withSessionRecordLockSync)
|
|
733
|
+
console.log(result.added ? `posted ${result.path}` : `already posted ${result.path}`)
|
|
734
|
+
} else if (verb === 'retract') {
|
|
735
|
+
const result = files.retractSessionFile(id, path!, withSessionRecordLockSync)
|
|
736
|
+
if (!result.removed) { console.error(`spex session files retract: path is not posted: ${result.path}`); process.exit(2) }
|
|
737
|
+
console.log(`retracted ${result.path}`)
|
|
738
|
+
} else {
|
|
739
|
+
console.error(`spex session files: unknown verb '${verb}' — add | ls | retract (spex help session)`)
|
|
740
|
+
process.exit(2)
|
|
741
|
+
}
|
|
742
|
+
} else if (sub === 'web') {
|
|
743
|
+
rejectUnknownFlags('spex session web', 4, [])
|
|
744
|
+
const [verb, url, extra] = positionals(4)
|
|
745
|
+
if (extra || !verb || (verb !== 'ls' && !url) || (verb === 'ls' && url)) {
|
|
746
|
+
console.error('usage: spex session web add <url> | ls | retract <url>')
|
|
747
|
+
process.exit(2)
|
|
748
|
+
}
|
|
749
|
+
const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
|
|
750
|
+
const id = ownSessionId()
|
|
751
|
+
if (!id) {
|
|
752
|
+
console.error('spex session web: no governed caller session — run this from the agent session that started the local service')
|
|
753
|
+
process.exit(2)
|
|
754
|
+
}
|
|
755
|
+
const web = await import('./session-web.js')
|
|
756
|
+
if (verb === 'ls') {
|
|
757
|
+
for (const entry of web.listSessionWebs(id)) console.log(entry.url)
|
|
758
|
+
} else if (verb === 'add') {
|
|
759
|
+
const result = web.addSessionWeb(id, url!, withSessionRecordLockSync)
|
|
760
|
+
console.log(result.added ? `posted ${result.url}` : `already posted ${result.url}`)
|
|
761
|
+
} else if (verb === 'retract') {
|
|
762
|
+
const result = web.retractSessionWeb(id, url!, withSessionRecordLockSync)
|
|
763
|
+
if (!result.removed) { console.error(`spex session web retract: URL is not posted: ${result.url}`); process.exit(2) }
|
|
764
|
+
console.log(`retracted ${result.url}`)
|
|
765
|
+
} else {
|
|
766
|
+
console.error(`spex session web: unknown verb '${verb}' — add | ls | retract (spex help session)`)
|
|
767
|
+
process.exit(2)
|
|
768
|
+
}
|
|
645
769
|
} else if (sub === 'watch') {
|
|
646
770
|
const [verb, ...rest] = positionals(4)
|
|
647
771
|
const { ownSessionId, subscribeSessionWatch, listSessionWatches, cancelSessionWatch } = await import('./sessions.js')
|
|
@@ -710,13 +834,13 @@ if (cmd === 'serve') {
|
|
|
710
834
|
if (first === 'proof') signpost('spex review proof', 'spex eval ls --session <SEL> --export') // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
|
|
711
835
|
const { clientReview } = await import('./client.js')
|
|
712
836
|
if (!first) { console.error('usage: spex session review <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
713
|
-
const
|
|
714
|
-
const r = await clientReview(id)
|
|
715
|
-
if (!r) { console.error(`no such session ${id}`); process.exit(1) }
|
|
837
|
+
const session = await resolveSessionOrExit(first)
|
|
838
|
+
const r = await clientReview(session.id)
|
|
839
|
+
if (!r) { console.error(`no such session ${session.id}`); process.exit(1) }
|
|
716
840
|
if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
|
|
717
841
|
else {
|
|
718
842
|
const g = r.gates
|
|
719
|
-
console.log(`review ${
|
|
843
|
+
console.log(`review ${session.title} [${r.id}]`)
|
|
720
844
|
console.log(` ahead of main : ${r.ahead} commit(s)`)
|
|
721
845
|
console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
|
|
722
846
|
console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
|
|
@@ -732,11 +856,17 @@ if (cmd === 'serve') {
|
|
|
732
856
|
for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
|
|
733
857
|
}
|
|
734
858
|
} else if (sub === 'merge') {
|
|
735
|
-
const { clientMerge } = await import('./client.js')
|
|
859
|
+
const [{ clientMerge, clientReview }, { createHash }] = await Promise.all([import('./client.js'), import('node:crypto')])
|
|
736
860
|
const sel = positionals(4)[0]
|
|
737
861
|
if (!sel) { console.error('usage: spex session merge <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
|
|
738
862
|
const id = await resolveSelectorOrExit(sel)
|
|
739
|
-
const
|
|
863
|
+
const review = await clientReview(id)
|
|
864
|
+
if (!review) { console.error(`merge dispatch failed: no such session ${id}`); process.exit(1) }
|
|
865
|
+
const r = await clientMerge(id, {
|
|
866
|
+
expectedBranchHead: review.branchHead,
|
|
867
|
+
expectedBaseHead: review.baseHead,
|
|
868
|
+
requestKey: createHash('sha256').update(`spexcode-cli-session-merge\0${id}\0${review.branchHead}\0${review.baseHead}`).digest('hex'),
|
|
869
|
+
})
|
|
740
870
|
if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
|
|
741
871
|
else console.error(`merge dispatch failed: ${r.reason}`)
|
|
742
872
|
process.exit(r.dispatched ? 0 : 1)
|
|
@@ -744,8 +874,9 @@ if (cmd === 'serve') {
|
|
|
744
874
|
// `s` (sessions.ts) backs the state PRODUCERS that stay local (done/park/ask write the global record by
|
|
745
875
|
// session_id) and the stateKit shared with `spex internal session-*`. `c` (client.ts) backs the
|
|
746
876
|
// read/control subs that route through the backend. Lazily imported.
|
|
877
|
+
const sendArgs = sub === 'send' ? parseSessionSendArgs(process.argv.slice(4)) : null
|
|
747
878
|
const c = await import('./client.js')
|
|
748
|
-
const id = process.argv[4]
|
|
879
|
+
const id = sendArgs?.selector ?? process.argv[4]
|
|
749
880
|
if (sub === 'resume') {
|
|
750
881
|
// bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
|
|
751
882
|
// `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts resumeSession()).
|
|
@@ -822,18 +953,30 @@ if (cmd === 'serve') {
|
|
|
822
953
|
const quarantined = await c.clientQuarantine(id, { adapter, thread: flag('thread') ?? null, tmux, worktree, branch })
|
|
823
954
|
console.log(`quarantined ${quarantined.id} -> ${quarantined.bundle}`)
|
|
824
955
|
}
|
|
956
|
+
} else if (sub === 'reparent') {
|
|
957
|
+
rejectUnknownFlags('spex session reparent', 4, ['to', 'api', 'port'])
|
|
958
|
+
const children = positionals(4)
|
|
959
|
+
const to = flag('to')
|
|
960
|
+
if (!children.length || !to) {
|
|
961
|
+
console.error('usage: spex session reparent <child-SEL...> --to <parent-SEL>')
|
|
962
|
+
process.exit(2)
|
|
963
|
+
}
|
|
964
|
+
const childIds = [...new Set(await Promise.all(children.map(resolveSelectorOrExit)))]
|
|
965
|
+
const parent = await resolveSelectorOrExit(to)
|
|
966
|
+
const { reparentSessions } = await import('./session-reparent.js')
|
|
967
|
+
const result = await reparentSessions(childIds, parent)
|
|
968
|
+
console.log(`reparented ${result.children.join(', ')} -> ${result.parent}`)
|
|
825
969
|
} else if (sub === 'send') {
|
|
970
|
+
if (!sendArgs) sessionSendUsage('arguments were not parsed')
|
|
826
971
|
const full = await resolveSelectorOrExit(id)
|
|
827
|
-
if (
|
|
972
|
+
if (sendArgs.kind === 'keys') {
|
|
828
973
|
// the LAST-RESORT face of send: forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt
|
|
829
974
|
// socket) — how a manager drives a worker wedged in an interactive TUI dialog the prompt channel
|
|
830
975
|
// can't reach (a select menu wanting one Enter/arrow). UNSTABLE, and able to confirm dangerous
|
|
831
976
|
// dialogs — try plain `session send` text FIRST; reach for --keys only when text provably can't
|
|
832
977
|
// land. Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as ONE
|
|
833
978
|
// ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
|
|
834
|
-
|
|
835
|
-
if (keys.length === 0) { console.error('usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'); process.exit(2) }
|
|
836
|
-
if (await c.clientSendRawKeys(full, keys)) { console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
|
|
979
|
+
if (await c.clientSendRawKeys(full, sendArgs.keys)) { console.log(`sent ${sendArgs.keys.length} key${sendArgs.keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
|
|
837
980
|
console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`)
|
|
838
981
|
process.exit(1)
|
|
839
982
|
}
|
|
@@ -853,7 +996,7 @@ if (cmd === 'serve') {
|
|
|
853
996
|
// the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
|
|
854
997
|
sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
|
|
855
998
|
}
|
|
856
|
-
const r = await c.clientSend(full, s.withSenderHint(
|
|
999
|
+
const r = await c.clientSend(full, s.withSenderHint(sendArgs.text, sender), senderId ?? undefined)
|
|
857
1000
|
console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`)
|
|
858
1001
|
process.exit(r.ok ? 0 : 1)
|
|
859
1002
|
} else if (sub === 'show') {
|
|
@@ -872,7 +1015,7 @@ if (cmd === 'serve') {
|
|
|
872
1015
|
if (has('json')) { console.log(JSON.stringify(r.session, null, 2)) }
|
|
873
1016
|
else {
|
|
874
1017
|
const x = r.session
|
|
875
|
-
console.log(`${x.
|
|
1018
|
+
console.log(`${x.title} [${x.id}]`)
|
|
876
1019
|
console.log(` status : ${x.status} (lifecycle ${x.lifecycle} · liveness ${x.liveness})`)
|
|
877
1020
|
console.log(` node : ${x.node ?? '—'}`)
|
|
878
1021
|
console.log(` branch : ${x.branch ?? '—'}`)
|
|
@@ -901,7 +1044,7 @@ if (cmd === 'serve') {
|
|
|
901
1044
|
await assertLocalBackend()
|
|
902
1045
|
process.exit(await attachSession(await resolveSelectorOrExit(id)))
|
|
903
1046
|
} else {
|
|
904
|
-
console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
1047
|
+
console.error(`spex session: unknown verb '${sub}' — new | ls | files | web | show | watch | wait | review | merge | reparent | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
|
|
905
1048
|
process.exit(2)
|
|
906
1049
|
}
|
|
907
1050
|
}
|
package/spec-cli/src/client.ts
CHANGED
|
@@ -2,26 +2,113 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { platform } from 'node:os'
|
|
3
3
|
import { repoRoot } from './git.js'
|
|
4
4
|
import { resourceBudgets, type ResourceReport } from './host-resources.js'
|
|
5
|
-
import { listSessionIds, readPublicRecordEntry } from './layout.js'
|
|
5
|
+
import { envSessionId, listSessionIds, readPublicRecordEntry } from './layout.js'
|
|
6
6
|
import { cockpitReview, type CockpitReview } from './cockpit.js'
|
|
7
|
-
import {
|
|
7
|
+
import { apiBaseInfo, assertProjectMatch, fromRaw, optionArgv, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
|
|
8
8
|
|
|
9
9
|
export class BackendError extends Error {
|
|
10
|
-
constructor(message: string, readonly status?: number) {
|
|
10
|
+
constructor(message: string, readonly status?: number, readonly transport?: unknown) {
|
|
11
11
|
super(message)
|
|
12
12
|
this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const usageError = (message: string): Error => {
|
|
17
|
+
const error = new Error(message)
|
|
18
|
+
error.name = 'UsageError'
|
|
19
|
+
return error
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hasFlag = (name: string): boolean => optionArgv().includes(`--${name}`)
|
|
23
|
+
function flagValue(name: string): string | null {
|
|
24
|
+
const argv = optionArgv()
|
|
25
|
+
const index = argv.indexOf(`--${name}`)
|
|
26
|
+
if (index < 0) return null
|
|
27
|
+
const value = argv[index + 1]
|
|
28
|
+
if (value === undefined || value.startsWith('--')) throw usageError(`--${name} expects a value`)
|
|
29
|
+
return value
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let gatewayCookie: { base: string; value: string } | null = null
|
|
33
|
+
|
|
34
|
+
function prepareTls(base: string): void {
|
|
35
|
+
if (!hasFlag('insecure')) return
|
|
36
|
+
if (new URL(base).protocol !== 'https:') throw usageError('--insecure requires an https --api endpoint')
|
|
37
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function passwordFor(target: Awaited<ReturnType<typeof apiBaseInfo>>): string | null {
|
|
41
|
+
const password = flagValue('password') ?? process.env.SPEXCODE_PASSWORD ?? null
|
|
42
|
+
if (!password) return null
|
|
43
|
+
if (target.source !== 'flag') throw usageError('--password and SPEXCODE_PASSWORD require explicit --api routing')
|
|
44
|
+
return password
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function withCookie(init: RequestInit | undefined, cookie: string): RequestInit {
|
|
48
|
+
const headers = new Headers(init?.headers)
|
|
49
|
+
headers.set('cookie', cookie)
|
|
50
|
+
return { ...init, headers }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loginGateway(base: string, password: string): Promise<string> {
|
|
54
|
+
let response: Response
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(`${base}/login`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
59
|
+
body: new URLSearchParams({ password }).toString(),
|
|
60
|
+
redirect: 'manual',
|
|
61
|
+
})
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new BackendError(`gateway login could not reach ${base}: ${(error as Error).message}`, undefined, error)
|
|
64
|
+
}
|
|
65
|
+
if (response.status < 200 || response.status >= 400) throw new BackendError(`gateway login rejected credentials at ${base}`, response.status)
|
|
66
|
+
const headers = response.headers as Headers & { getSetCookie?: () => string[] }
|
|
67
|
+
const cookies = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']
|
|
68
|
+
const cookie = cookies.map((value) => value.split(';', 1)[0]).find(Boolean)
|
|
69
|
+
if (!cookie) throw new BackendError(`gateway login at ${base} did not return an authorization cookie`, response.status)
|
|
70
|
+
return cookie
|
|
71
|
+
}
|
|
72
|
+
|
|
16
73
|
// the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
|
|
17
74
|
// is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
|
|
18
75
|
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
76
|
+
const target = await apiBaseInfo()
|
|
77
|
+
const base = target.url
|
|
78
|
+
prepareTls(base)
|
|
79
|
+
const request = async (cookie?: string): Promise<Response> => {
|
|
80
|
+
try {
|
|
81
|
+
return await fetch(`${base}${path}`, cookie ? withCookie(init, cookie) : init)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(error as Error).message})`, undefined, error)
|
|
84
|
+
}
|
|
24
85
|
}
|
|
86
|
+
const existing = gatewayCookie?.base === base ? gatewayCookie.value : undefined
|
|
87
|
+
const response = await request(existing)
|
|
88
|
+
if (response.status !== 401) return response
|
|
89
|
+
if (existing) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
|
|
90
|
+
const password = passwordFor(target)
|
|
91
|
+
if (!password) throw new BackendError(`authentication required at ${base} — pass --password <pw> or set SPEXCODE_PASSWORD`, 401)
|
|
92
|
+
const cookie = await loginGateway(base, password)
|
|
93
|
+
gatewayCookie = { base, value: cookie }
|
|
94
|
+
const retried = await request(cookie)
|
|
95
|
+
if (retried.status === 401) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
|
|
96
|
+
return retried
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function backendConnectionRefused(error: unknown): boolean {
|
|
100
|
+
if (!(error instanceof BackendError)) return false
|
|
101
|
+
let current = error.transport
|
|
102
|
+
let sawRefusal = false
|
|
103
|
+
const seen = new Set<unknown>()
|
|
104
|
+
while (current && typeof current === 'object' && !seen.has(current)) {
|
|
105
|
+
seen.add(current)
|
|
106
|
+
const code = (current as NodeJS.ErrnoException).code
|
|
107
|
+
if (code && code !== 'ECONNREFUSED') return false
|
|
108
|
+
if (code === 'ECONNREFUSED') sawRefusal = true
|
|
109
|
+
current = (current as { cause?: unknown }).cause
|
|
110
|
+
}
|
|
111
|
+
return sawRefusal
|
|
25
112
|
}
|
|
26
113
|
// every MUTATING verb is project-bound ([[remote-client]]'s write guard): resolve the backend, compare its
|
|
27
114
|
// served root to the cwd project, refuse loudly on a same-host mismatch — an explicit --api/--port skips it.
|
|
@@ -211,10 +298,14 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
|
|
|
211
298
|
throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
|
|
212
299
|
}
|
|
213
300
|
|
|
214
|
-
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH
|
|
215
|
-
|
|
301
|
+
// POST /api/sessions/:id/merge — the cockpit's merge DISPATCH. The caller returns the exact pair it just
|
|
302
|
+
// reviewed and one durable request key; the backend accepts that decision once before ensuring the agent live.
|
|
303
|
+
export async function clientMerge(id: string, options: { expectedBranchHead: string; expectedBaseHead: string; requestKey: string }): Promise<{ dispatched: boolean; replayed?: boolean; expectedBranchHead?: string; expectedBaseHead?: string; reason?: string; code?: string }> {
|
|
216
304
|
await guarded('merge')
|
|
217
|
-
const
|
|
305
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', 'Idempotency-Key': options.requestKey }
|
|
306
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, {
|
|
307
|
+
method: 'POST', headers, body: JSON.stringify({ expectedBranchHead: options.expectedBranchHead, expectedBaseHead: options.expectedBaseHead }),
|
|
308
|
+
})
|
|
218
309
|
return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
|
|
219
310
|
}
|
|
220
311
|
|
|
@@ -245,10 +336,11 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
|
|
|
245
336
|
return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
|
|
246
337
|
}
|
|
247
338
|
|
|
248
|
-
// POST /api/sessions/:id/close —
|
|
339
|
+
// POST /api/sessions/:id/close — terminal worktree removal. A client-side session id is only an unverified claim.
|
|
249
340
|
export async function clientClose(id: string): Promise<boolean> {
|
|
250
341
|
await guarded('session close')
|
|
251
|
-
const
|
|
342
|
+
const source = envSessionId()
|
|
343
|
+
const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({ source: source ? { kind: 'unverified-session-claim', id: source } : { kind: 'user' } }))
|
|
252
344
|
if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
|
|
253
345
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
254
346
|
}
|
|
@@ -291,6 +383,13 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
|
|
|
291
383
|
return !!(await r.json().catch(() => ({ ok: false })))?.ok
|
|
292
384
|
}
|
|
293
385
|
|
|
386
|
+
export async function clientReparent(children: string[], parent: string): Promise<import('./sessions.js').SessionReparentResult> {
|
|
387
|
+
await guarded('session reparent')
|
|
388
|
+
const r = await apiFetch('/api/sessions/reparent', post({ children, parent }))
|
|
389
|
+
if (!r.ok) throw new BackendError(`backend refused to reparent sessions: ${await r.text()}`, r.status)
|
|
390
|
+
return await r.json() as import('./sessions.js').SessionReparentResult
|
|
391
|
+
}
|
|
392
|
+
|
|
294
393
|
// POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
|
|
295
394
|
// NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
|
|
296
395
|
// ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
|
|
@@ -8,9 +8,16 @@ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
|
8
8
|
// or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
|
|
9
9
|
// queue is only ever filled by an enqueue — which is why no backlog migration exists.
|
|
10
10
|
|
|
11
|
-
export type PendingMessage = {
|
|
11
|
+
export type PendingMessage = {
|
|
12
|
+
mid: string
|
|
13
|
+
text: string
|
|
14
|
+
from: string | null
|
|
15
|
+
dispatch?: { operation: 'merge'; requestDigest: string }
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
|
|
19
|
+
const revokedSenderRoot = (): string => join(runtimeRoot(), '.revoked-senders')
|
|
20
|
+
const revokedSenderPath = (id: string): string => join(revokedSenderRoot(), id)
|
|
14
21
|
|
|
15
22
|
// @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
|
|
16
23
|
// is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
|
|
@@ -53,7 +60,10 @@ function read(id: string): PendingMessage[] {
|
|
|
53
60
|
return raw.filter((m): m is PendingMessage =>
|
|
54
61
|
!!m && typeof m === 'object'
|
|
55
62
|
&& typeof (m as PendingMessage).mid === 'string'
|
|
56
|
-
&& typeof (m as PendingMessage).text === 'string'
|
|
63
|
+
&& typeof (m as PendingMessage).text === 'string'
|
|
64
|
+
&& ((m as PendingMessage).dispatch === undefined
|
|
65
|
+
|| ((m as PendingMessage).dispatch?.operation === 'merge'
|
|
66
|
+
&& typeof (m as PendingMessage).dispatch?.requestDigest === 'string')))
|
|
57
67
|
} catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -68,17 +78,59 @@ function write(id: string, msgs: PendingMessage[]): void {
|
|
|
68
78
|
renameSync(tmp, path)
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
// A closed sender may have left debt in many other sessions' queues. The marker is deliberately outside its
|
|
82
|
+
// own store (which close removes) and is checked under the sender's record lock by dispatch: a close cannot
|
|
83
|
+
// return while an old process can still append, and a later sweep cannot hand over what it sees here.
|
|
84
|
+
export function revokeSenderDelivery(id: string): void {
|
|
85
|
+
mkdirSync(revokedSenderRoot(), { recursive: true })
|
|
86
|
+
writeFileSync(revokedSenderPath(id), `${id}\n`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const senderDeliveryRevoked = (id: string): boolean => existsSync(revokedSenderPath(id))
|
|
90
|
+
|
|
91
|
+
export function pendingSnapshot(id: string): PendingMessage[] { return read(id) }
|
|
92
|
+
|
|
93
|
+
// These two writes require the target's delivery lock. They are the queue half of a larger transaction
|
|
94
|
+
// (currently reparent), which must be able to restore the exact previous debt if a later record write fails.
|
|
95
|
+
export function replacePendingWhileLocked(id: string, msgs: PendingMessage[]): void { write(id, msgs) }
|
|
96
|
+
export function revokePendingFromWhileLocked(id: string, sender: string): number {
|
|
97
|
+
const current = read(id)
|
|
98
|
+
const next = current.filter((msg) => msg.from !== sender)
|
|
99
|
+
write(id, next)
|
|
100
|
+
return current.length - next.length
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
// The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
|
|
72
|
-
// both, and the record is written first, so
|
|
73
|
-
//
|
|
104
|
+
// both, and the record is written first, so delivery is never unrecorded. A keyed dispatch also carries its
|
|
105
|
+
// exact delivery bytes in the timeline receipt, letting its retry restore a queue write lost to a crash.
|
|
74
106
|
export function enqueue(id: string, msg: PendingMessage): void {
|
|
75
107
|
write(id, [...read(id), msg])
|
|
76
108
|
}
|
|
77
109
|
|
|
110
|
+
// Caller holds this target's delivery lock. A retry may reach this after either side of the receipt->queue
|
|
111
|
+
// crash boundary; exact mid identity makes reconstruction idempotent without inspecting message text.
|
|
112
|
+
export function ensurePendingWhileLocked(id: string, msg: PendingMessage): boolean {
|
|
113
|
+
const current = read(id)
|
|
114
|
+
if (current.some((pending) => pending.mid === msg.mid)) return false
|
|
115
|
+
write(id, [...current, msg])
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
78
119
|
export const pendingMessages = (id: string): PendingMessage[] => read(id)
|
|
79
120
|
|
|
80
121
|
export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
|
|
81
122
|
|
|
123
|
+
// Record transitions and queue mutations take locks in the same direction: record locks first, then these
|
|
124
|
+
// target queue locks. A batch reparent needs all child queues held at once so its pointer/watch/debt change
|
|
125
|
+
// either commits together or restores together.
|
|
126
|
+
export async function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index = 0, ids = [...new Set(rawIds)].sort()): Promise<T> {
|
|
127
|
+
if (index >= ids.length) return body()
|
|
128
|
+
const release = await acquire(ids[index], 30_000)
|
|
129
|
+
if (!release) throw new Error(`delivery queue ${ids[index]}: timed out waiting for transaction lock`)
|
|
130
|
+
try { return await withDeliveryLocks(ids, body, index + 1, ids) }
|
|
131
|
+
finally { release() }
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
// Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
|
|
83
135
|
// then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
|
|
84
136
|
// still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
|
|
@@ -95,6 +147,12 @@ export async function drain(
|
|
|
95
147
|
for (;;) {
|
|
96
148
|
const queued = read(id)
|
|
97
149
|
if (!queued.length) return { delivered, remaining: 0 }
|
|
150
|
+
if (queued[0].from && senderDeliveryRevoked(queued[0].from)) {
|
|
151
|
+
// Closing a sender voids its undelivered output, not the recipient's immutable conversation history.
|
|
152
|
+
// Drop a revoked head and continue so it cannot permanently block the messages behind it.
|
|
153
|
+
write(id, read(id).filter((m) => m.mid !== queued[0].mid))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
98
156
|
let ok = false
|
|
99
157
|
try { ok = await insert(queued[0]) } catch { ok = false }
|
|
100
158
|
if (!ok) return { delivered, remaining: queued.length }
|