tumwater 0.1.0 → 0.1.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 +94 -25
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +34 -0
- package/dist/src/agent/claude/claude-account.js +243 -0
- package/dist/src/agent/claude/claude-args.js +104 -0
- package/dist/src/agent/claude/claude-backend.js +162 -0
- package/dist/src/agent/claude/claude-env.js +84 -0
- package/dist/src/agent/claude/claude-stream.js +384 -0
- package/dist/src/agent/mcp/role-notes-server.js +160 -0
- package/dist/src/agent/role-notes-write.js +65 -0
- package/dist/src/agent/run-agent.js +439 -0
- package/dist/src/agent/session-ledger.js +169 -0
- package/dist/src/backlog/backlog-eligibility.js +195 -0
- package/dist/src/{backlog.js → backlog/backlog-md.js} +86 -82
- package/dist/src/backlog/backlog-render.js +28 -0
- package/dist/src/backlog/backlog-structure.js +355 -0
- package/dist/src/backlog/backlog-write.js +653 -0
- package/dist/src/backlog/backlog.js +88 -0
- package/dist/src/baseline/main-baseline.js +357 -0
- package/dist/src/{main-red.js → baseline/main-red.js} +64 -50
- package/dist/src/{readme.js → brief.js} +17 -7
- package/dist/src/{budget.js → budget/budget.js} +62 -28
- package/dist/src/{fallback-breaker.js → budget/fallback-breaker.js} +60 -10
- package/dist/src/build/build-check-counts.js +58 -0
- package/dist/src/{build-check-detect.js → build/build-check-detect.js} +34 -47
- package/dist/src/{build-check-events.js → build/build-check-events.js} +69 -10
- package/dist/src/{build-check-report.js → build/build-check-report.js} +9 -9
- package/dist/src/{build-check-scoped.js → build/build-check-scoped.js} +40 -40
- package/dist/src/{build-check.js → build/build-check.js} +11 -8
- package/dist/src/{build-info.js → build/build-info.js} +12 -6
- package/dist/src/{build-stage.js → build/build-stage.js} +105 -30
- package/dist/src/{dep-install.js → build/dep-install.js} +14 -7
- package/dist/src/{host-sleep.js → build/host-sleep.js} +6 -4
- package/dist/src/{change-data.js → change/change-data.js} +21 -18
- package/dist/src/{ui/change-preview.js → change/change-render.js} +14 -7
- package/dist/src/{cli-args.js → cli/cli-args.js} +113 -37
- package/dist/src/{cli-command-args.js → cli/cli-command-args.js} +167 -85
- package/dist/src/cli/cli-flag-specs.js +321 -0
- package/dist/src/cli/cli-marker-commands.js +44 -0
- package/dist/src/cli/cli-output.js +63 -0
- package/dist/src/cli/cli-query-commands.js +249 -0
- package/dist/src/cli/cli-repo-gate.js +15 -0
- package/dist/src/cli/cli-run.js +376 -0
- package/dist/src/cli/config-commands.js +131 -0
- package/dist/src/cli/help.js +348 -0
- package/dist/src/{ui → cli}/log-commands.js +23 -23
- package/dist/src/cli/notify-command.js +68 -0
- package/dist/src/{question-commands.js → cli/question-commands.js} +87 -50
- package/dist/src/cli.js +143 -268
- package/dist/src/collections.js +77 -0
- package/dist/src/{check-permit.js → concurrency/check-permit.js} +14 -15
- package/dist/src/concurrency/lock.js +236 -0
- package/dist/src/{semaphore.js → concurrency/semaphore.js} +44 -18
- package/dist/src/config/config-editable-keys.js +26 -0
- package/dist/src/{config-example.js → config/config-example.js} +33 -17
- package/dist/src/config/config-field-checks.js +216 -0
- package/dist/src/{config-live.js → config/config-live.js} +50 -17
- package/dist/src/{config-schema.js → config/config-schema.js} +47 -4
- package/dist/src/config/config-validation.js +468 -0
- package/dist/src/config/config-views.js +528 -0
- package/dist/src/config/config-write.js +641 -0
- package/dist/src/{config.js → config/config.js} +123 -31
- package/dist/src/config/model-selector.js +74 -0
- package/dist/src/{doctor-backlog.js → doctor/doctor-backlog.js} +35 -38
- package/dist/src/doctor/doctor-checks.js +457 -0
- package/dist/src/doctor/doctor-holds.js +122 -0
- package/dist/src/doctor/doctor-launch-services.js +22 -0
- package/dist/src/doctor/doctor-model-checks.js +396 -0
- package/dist/src/{doctor-orphans.js → doctor/doctor-orphans.js} +12 -20
- package/dist/src/doctor/doctor-render.js +13 -0
- package/dist/src/{doctor.js → doctor/doctor.js} +31 -10
- package/dist/src/doctor/outcome.js +16 -0
- package/dist/src/errno.js +1 -1
- package/dist/src/events/event-format.js +446 -0
- package/dist/src/{event-read.js → events/event-read.js} +59 -25
- package/dist/src/{event-window.js → events/event-window.js} +106 -18
- package/dist/src/{events.js → events/events.js} +67 -34
- package/dist/src/{notify.js → events/notify.js} +26 -14
- package/dist/src/{failure-cluster.js → failure/failure-cluster.js} +52 -19
- package/dist/src/failure/failure-data.js +356 -0
- package/dist/src/{failure-report.js → failure/failure-render.js} +110 -66
- package/dist/src/{failure-state-change.js → failure/failure-state-change.js} +65 -26
- package/dist/src/failure/time-spend.js +240 -0
- package/dist/src/{file-queue.js → files/file-queue.js} +1 -1
- package/dist/src/files/files.js +376 -0
- package/dist/src/{json-files.js → files/json-files.js} +18 -15
- package/dist/src/files/json-object.js +94 -0
- package/dist/src/{stat-cache.js → files/stat-cache.js} +16 -3
- package/dist/src/{tail.js → files/tail.js} +14 -13
- package/dist/src/{error-storm.js → fleet/error-storm.js} +9 -10
- package/dist/src/{failure-spread.js → fleet/failure-spread.js} +7 -8
- package/dist/src/{fleet-hold.js → fleet/fleet-hold.js} +77 -21
- package/dist/src/fleet/fleet-polls.js +207 -0
- package/dist/src/{fleet-state.js → fleet/fleet-state.js} +98 -56
- package/dist/src/fleet/orchestrator-info.js +150 -0
- package/dist/src/fleet/reclaim.js +299 -0
- package/dist/src/gates/bootstrap-gates.js +101 -0
- package/dist/src/gates/budget-gates.js +218 -0
- package/dist/src/gates/disk-gate.js +78 -0
- package/dist/src/gates/gate-polls.js +283 -0
- package/dist/src/gates/maintenance-quota.js +198 -0
- package/dist/src/{pause-gates.js → gates/pause-gates.js} +10 -10
- package/dist/src/gates/readiness.js +11 -0
- package/dist/src/gates/role-cap-gates.js +135 -0
- package/dist/src/gates/startup-gate.js +124 -0
- package/dist/src/{streak-gate.js → gates/streak-gate.js} +31 -33
- package/dist/src/{commit-message.js → git/commit-message.js} +49 -14
- package/dist/src/{git-diff.js → git/git-diff.js} +81 -46
- package/dist/src/git/git-run.js +237 -0
- package/dist/src/git/git.js +488 -0
- package/dist/src/git/slots-state.js +109 -0
- package/dist/src/git/worktree-pool.js +399 -0
- package/dist/src/git/worktree-use.js +173 -0
- package/dist/src/git/worktree.js +263 -0
- package/dist/src/git/xcrun-git.js +24 -0
- package/dist/src/{ui → gui}/gui-args.js +68 -18
- package/dist/src/{ui/gui.js → gui/gui-command.js} +5 -8
- package/dist/src/gui/gui-endpoint-commands.js +671 -0
- package/dist/src/gui/gui-endpoints.js +322 -0
- package/dist/src/gui/gui-server.js +270 -0
- package/dist/src/{ui → gui}/http-body.js +35 -16
- package/dist/src/{history-data.js → history/history-data.js} +161 -68
- package/dist/src/{ui → history}/history.js +59 -60
- package/dist/src/inbox/inbox-attachments.js +286 -0
- package/dist/src/inbox/inbox-cancel.js +118 -0
- package/dist/src/inbox/inbox-edit.js +144 -0
- package/dist/src/{inbox-submit.js → inbox/inbox-submit.js} +46 -22
- package/dist/src/inbox/inbox.js +371 -0
- package/dist/src/inbox/pending-prompt.js +148 -0
- package/dist/src/inbox/prompt-commands.js +222 -0
- package/dist/src/inbox/prompt-not-before.js +61 -0
- package/dist/src/{init-templates.js → init/init-templates.js} +78 -4
- package/dist/src/{init.js → init/init.js} +89 -92
- package/dist/src/landing/backlog-conflicts.js +116 -0
- package/dist/src/{landing-batch.js → landing/landing-batch.js} +112 -73
- package/dist/src/{landing-check-failures.js → landing/landing-check-failures.js} +66 -40
- package/dist/src/{landing-core.js → landing/landing-core.js} +109 -23
- package/dist/src/landing/landing-diff.js +57 -0
- package/dist/src/{landing-drain.js → landing/landing-drain.js} +66 -17
- package/dist/src/{landing-git.js → landing/landing-git.js} +78 -18
- package/dist/src/landing/landing-merge.js +308 -0
- package/dist/src/{landing-pipeline.js → landing/landing-pipeline.js} +33 -9
- package/dist/src/landing/landing-questions.js +16 -0
- package/dist/src/landing/landing-queue-render.js +31 -0
- package/dist/src/{landing-queue.js → landing/landing-queue.js} +60 -20
- package/dist/src/{landing-slot.js → landing/landing-slot.js} +80 -40
- package/dist/src/{landing-stack.js → landing/landing-stack.js} +35 -21
- package/dist/src/{landing-vetting.js → landing/landing-vetting.js} +27 -22
- package/dist/src/{leftover.js → loop/leftover.js} +53 -20
- package/dist/src/loop/loop-fallback.js +66 -0
- package/dist/src/loop/loop-pi.js +370 -0
- package/dist/src/loop/loop-recovery.js +90 -0
- package/dist/src/loop/loop-state.js +195 -0
- package/dist/src/loop/loop-tick.js +539 -0
- package/dist/src/loop/loop.js +311 -0
- package/dist/src/loop/model-fallback.js +75 -0
- package/dist/src/loop/revision.js +61 -0
- package/dist/src/{operator-commands.js → operator/operator-commands.js} +140 -43
- package/dist/src/{operator-intent.js → operator/operator-intent.js} +133 -42
- package/dist/src/operator/operator-requests.js +340 -0
- package/dist/src/operator/retire.js +145 -0
- package/dist/src/orchestrator/orchestrator-launch.js +159 -0
- package/dist/src/orchestrator/orchestrator-scheduling.js +304 -0
- package/dist/src/{orchestrator.js → orchestrator/orchestrator.js} +196 -185
- package/dist/src/{retention.js → orchestrator/retention.js} +15 -6
- package/dist/src/paths.js +135 -22
- package/dist/src/{command-shape.js → pi/command-shape.js} +15 -3
- package/dist/src/{pi-args.js → pi/pi-args.js} +8 -6
- package/dist/src/pi/pi-backend.js +90 -0
- package/dist/src/pi/pi-bin.js +138 -0
- package/dist/src/{pi-event-line.js → pi/pi-event-line.js} +26 -25
- package/dist/src/pi/pi-models.js +197 -0
- package/dist/src/pi/pi-run-result.js +7 -0
- package/dist/src/{pi-stream.js → pi/pi-stream.js} +252 -48
- package/dist/src/pi/pi-watchdogs.js +193 -0
- package/dist/src/pi/pi.js +9 -0
- package/dist/src/pi-extension/bounded-output.js +5 -51
- package/dist/src/pi-extension/context-budget.js +44 -37
- package/dist/src/pi-extension/context-shake.js +241 -0
- package/dist/src/pi-extension/context-usage.js +20 -0
- package/dist/src/pi-extension/full-output.js +62 -0
- package/dist/src/pi-extension/role-notes.js +40 -0
- package/dist/src/pi-extension/tool-result-content.js +15 -0
- package/dist/src/{launch-services.js → process/launch-services.js} +16 -30
- package/dist/src/{process-group.js → process/process-group.js} +79 -51
- package/dist/src/{process-table.js → process/process-table.js} +23 -13
- package/dist/src/{process.js → process/process.js} +66 -22
- package/dist/src/{run-marker.js → process/run-marker.js} +22 -9
- package/dist/src/process/supervisor.js +205 -0
- package/dist/src/prompt/dialect.js +35 -0
- package/dist/src/prompt/gate-prompts.js +409 -0
- package/dist/src/prompt/principles.js +14 -0
- package/dist/src/{prompt-followup.js → prompt/prompt-followup.js} +31 -9
- package/dist/src/{prompt.js → prompt/prompt.js} +111 -51
- package/dist/src/{redeploy-policy.js → redeploy/redeploy-policy.js} +15 -11
- package/dist/src/{redeploy-probes.js → redeploy/redeploy-probes.js} +5 -2
- package/dist/src/{redeploy.js → redeploy/redeploy.js} +24 -18
- package/dist/src/{redeployer.js → redeploy/redeployer.js} +46 -18
- package/dist/src/{self-reload.js → redeploy/self-reload.js} +29 -3
- package/dist/src/report/report-data.js +425 -0
- package/dist/src/{ui → report}/report-render.js +62 -35
- package/dist/src/{ui → report}/report.js +32 -19
- package/dist/src/request-timeouts.js +6 -0
- package/dist/src/review/known-flakes.js +45 -0
- package/dist/src/{review-followup.js → review/review-followup.js} +40 -44
- package/dist/src/{review-precheck.js → review/review-precheck.js} +38 -20
- package/dist/src/review/review-verdict.js +177 -0
- package/dist/src/{review.js → review/review.js} +193 -71
- package/dist/src/{suite-rerun.js → review/suite-rerun.js} +6 -5
- package/dist/src/roles/loop-ids.js +60 -0
- package/dist/src/{role-catalog.js → roles/role-catalog.js} +175 -29
- package/dist/src/{role-guidance.js → roles/role-guidance.js} +23 -10
- package/dist/src/{ui/role-report.js → roles/role-render.js} +49 -13
- package/dist/src/{role-view.js → roles/role-view.js} +44 -17
- package/dist/src/{roles.js → roles/roles.js} +49 -22
- package/dist/src/{backoff.js → scheduling/backoff.js} +13 -3
- package/dist/src/scheduling/claims.js +149 -0
- package/dist/src/{once-round.js → scheduling/once-round.js} +3 -6
- package/dist/src/{quiet-hours.js → scheduling/quiet-hours.js} +30 -11
- package/dist/src/{scheduling.js → scheduling/scheduling.js} +33 -43
- package/dist/src/{work-landed-cache.js → scheduling/work-landed-cache.js} +4 -4
- package/dist/src/status/status-data.js +272 -0
- package/dist/src/status/status-polls.js +202 -0
- package/dist/src/{datetime.js → text/datetime.js} +70 -16
- package/dist/src/text/format.js +58 -0
- package/dist/src/text/markdown.js +30 -0
- package/dist/src/text/phrases.js +228 -0
- package/dist/src/text/suggest.js +66 -0
- package/dist/src/{text-width.js → text/text-width.js} +1 -1
- package/dist/src/text/text.js +199 -0
- package/dist/src/{qa-coverage.js → tick/qa-coverage.js} +14 -6
- package/dist/src/tick/stage-check.js +298 -0
- package/dist/src/{telemetry-digest.js → tick/telemetry-digest.js} +6 -6
- package/dist/src/{tick-apply.js → tick/tick-apply.js} +69 -15
- package/dist/src/{tick-detail-data.js → tick/tick-detail-data.js} +9 -8
- package/dist/src/tick/tick-detail.js +122 -0
- package/dist/src/{tick-finalize.js → tick/tick-finalize.js} +59 -17
- package/dist/src/tick/tick-prompt.js +201 -0
- package/dist/src/tick/tick-resume.js +115 -0
- package/dist/src/tick/tick-stage.js +193 -0
- package/dist/src/{tick-timing.js → tick/tick-timing.js} +67 -63
- package/dist/src/tick/tick-usage.js +142 -0
- package/dist/src/tick/tick-verdict.js +200 -0
- package/dist/src/ui/badges.js +155 -24
- package/dist/src/ui/fleet-alerts.js +35 -16
- package/dist/src/ui/{gui-client-boot.js → gui/gui-client-boot.js} +101 -7
- package/dist/src/ui/{gui-client-composer.js → gui/gui-client-composer.js} +51 -14
- package/dist/src/ui/gui/gui-client-drawer.js +392 -0
- package/dist/src/ui/gui/gui-client-fleet.js +568 -0
- package/dist/src/ui/{gui-client-history.js → gui/gui-client-history.js} +84 -37
- package/dist/src/ui/{gui-client-loops.js → gui/gui-client-loops.js} +98 -12
- package/dist/src/ui/{gui-client-model.js → gui/gui-client-model.js} +48 -21
- package/dist/src/ui/{gui-client-operator.js → gui/gui-client-operator.js} +95 -25
- package/dist/src/ui/gui/gui-client-pending.js +109 -0
- package/dist/src/ui/gui/gui-client-report.js +420 -0
- package/dist/src/ui/gui/gui-client-settings.js +122 -0
- package/dist/src/ui/{gui-client.js → gui/gui-client.js} +116 -19
- package/dist/src/ui/{gui-page.js → gui/gui-page.js} +15 -7
- package/dist/src/ui/{gui-styles.js → gui/gui-styles.js} +11 -5
- package/dist/src/{progress-data.js → ui/progress-data.js} +92 -60
- package/dist/src/ui/status-model.js +140 -149
- package/dist/src/ui/status-payload.js +88 -31
- package/dist/src/ui/status-render.js +58 -20
- package/dist/src/ui/tick-progress-model.js +117 -0
- package/dist/src/ui/tone.js +28 -9
- package/dist/src/ui/transcript-tail.js +38 -14
- package/dist/src/ui/transcript.js +49 -40
- package/dist/src/ui/{tui-app.js → tui/tui-app.js} +2 -4
- package/dist/src/ui/{tui-backlog.js → tui/tui-backlog.js} +11 -8
- package/dist/src/ui/tui/tui-frame.js +162 -0
- package/dist/src/ui/tui/tui-history.js +29 -0
- package/dist/src/ui/{tui-input.js → tui/tui-input.js} +108 -90
- package/dist/src/ui/tui/tui-keys.js +1783 -0
- package/dist/src/ui/tui/tui-pane.js +175 -0
- package/dist/src/ui/tui/tui-prompt-history.js +80 -0
- package/dist/src/ui/{tui.js → tui/tui.js} +53 -73
- package/dist/src/{fix-claim.js → verdict/fix-claim.js} +62 -47
- package/dist/src/{refusal.js → verdict/refusal.js} +11 -5
- package/dist/src/{reply-contract.js → verdict/reply-contract.js} +54 -42
- package/dist/src/version.js +3 -3
- package/package.json +3 -1
- package/dist/src/backlog-structure.js +0 -166
- package/dist/src/budget-gates.js +0 -128
- package/dist/src/build-check-counts.js +0 -36
- package/dist/src/cli-flag-specs.js +0 -185
- package/dist/src/cli-output.js +0 -44
- package/dist/src/cli-run.js +0 -243
- package/dist/src/config-commands.js +0 -58
- package/dist/src/config-field-checks.js +0 -132
- package/dist/src/config-validation.js +0 -307
- package/dist/src/config-views.js +0 -106
- package/dist/src/config-write.js +0 -217
- package/dist/src/doctor-checks.js +0 -301
- package/dist/src/event-format.js +0 -261
- package/dist/src/failure-data.js +0 -216
- package/dist/src/files.js +0 -168
- package/dist/src/fleet-polls.js +0 -113
- package/dist/src/gate-polls.js +0 -150
- package/dist/src/gate-prompts.js +0 -210
- package/dist/src/git.js +0 -333
- package/dist/src/help.js +0 -185
- package/dist/src/inbox-attachments.js +0 -114
- package/dist/src/inbox.js +0 -392
- package/dist/src/json-object.js +0 -31
- package/dist/src/landing-merge.js +0 -285
- package/dist/src/lock.js +0 -165
- package/dist/src/loop-pi.js +0 -218
- package/dist/src/loop-state.js +0 -44
- package/dist/src/loop.js +0 -541
- package/dist/src/main-baseline.js +0 -195
- package/dist/src/operator-requests.js +0 -114
- package/dist/src/orchestrator-launch.js +0 -119
- package/dist/src/pending-prompt.js +0 -96
- package/dist/src/phrases.js +0 -116
- package/dist/src/pi-models.js +0 -114
- package/dist/src/pi.js +0 -333
- package/dist/src/prompt-commands.js +0 -188
- package/dist/src/rank.js +0 -18
- package/dist/src/readiness.js +0 -78
- package/dist/src/report-data.js +0 -186
- package/dist/src/review-verdict.js +0 -80
- package/dist/src/role-cap-gates.js +0 -76
- package/dist/src/startup-gate.js +0 -88
- package/dist/src/status-data.js +0 -133
- package/dist/src/status-polls.js +0 -133
- package/dist/src/supervisor.js +0 -127
- package/dist/src/text.js +0 -183
- package/dist/src/tick-prompt.js +0 -120
- package/dist/src/tick-resume.js +0 -54
- package/dist/src/tick-stage.js +0 -95
- package/dist/src/tick-usage.js +0 -67
- package/dist/src/tick-verdict.js +0 -114
- package/dist/src/time-spend.js +0 -176
- package/dist/src/ui/backlog-report.js +0 -36
- package/dist/src/ui/doctor-report.js +0 -9
- package/dist/src/ui/gui-client-drawer.js +0 -219
- package/dist/src/ui/gui-client-fleet.js +0 -308
- package/dist/src/ui/gui-client-report.js +0 -276
- package/dist/src/ui/gui-client-settings.js +0 -70
- package/dist/src/ui/gui-endpoint-commands.js +0 -296
- package/dist/src/ui/gui-endpoints.js +0 -142
- package/dist/src/ui/gui-server.js +0 -198
- package/dist/src/ui/tick-detail.js +0 -87
- package/dist/src/ui/tui-frame.js +0 -111
- package/dist/src/ui/tui-keys.js +0 -439
- package/dist/src/worktree.js +0 -154
- /package/dist/src/{exemptions.js → review/exemptions.js} +0 -0
- /package/dist/src/{tick-outcome.js → tick/tick-outcome.js} +0 -0
- /package/dist/src/ui/{gui-client-markdown.js → gui/gui-client-markdown.js} +0 -0
- /package/dist/src/ui/{gui-client-sound.js → gui/gui-client-sound.js} +0 -0
- /package/dist/src/ui/{gui-icons.js → gui/gui-icons.js} +0 -0
- /package/dist/src/ui/{tui-keymap.js → tui/tui-keymap.js} +0 -0
- /package/dist/src/{no-change.js → verdict/no-change.js} +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { openBugEntries, openQuestionEntries, plannedPlanEntries } from "../backlog/backlog.js";
|
|
2
|
+
import { runDoctor } from "../doctor/doctor.js";
|
|
3
|
+
import { loadConfigSafe } from "../config/config.js";
|
|
4
|
+
import { EDITABLE_CONFIG_KEYS } from "../config/config-editable-keys.js";
|
|
5
|
+
import { collectReport, collectReportSince } from "../report/report-data.js";
|
|
6
|
+
import { collectFailureReport, collectFailureReportSince } from "../failure/failure-data.js";
|
|
7
|
+
import { renderFailureMarkdown } from "../failure/failure-render.js";
|
|
8
|
+
import { renderSinceReportMarkdown } from "../report/report-render.js";
|
|
9
|
+
import { REPORT_SINCE_MAX_MS } from "../events/event-window.js";
|
|
10
|
+
import { humanSeconds } from "../text/datetime.js";
|
|
11
|
+
import { collectFleetChanges, collectRoleChange } from "../change/change-data.js";
|
|
12
|
+
import { readTranscript } from "../ui/transcript.js";
|
|
13
|
+
import { HISTORY_DEFAULT_TICKS, HISTORY_MAX_TICKS, readTickRows, readTickRowsSince, tickRowsMatchingGrep } from "../history/history-data.js";
|
|
14
|
+
import { LOGS_SINCE_MAX_MS, SPARSE_WINDOW_NOTE } from "../events/event-window.js";
|
|
15
|
+
import { eventRole, readEvents } from "../events/event-read.js";
|
|
16
|
+
import { eventFeedItem, eventMatchesGrep } from "../events/event-format.js";
|
|
17
|
+
import { readTickDetail } from "../tick/tick-detail-data.js";
|
|
18
|
+
import { renderTickDetail, tickNotFoundMessage } from "../tick/tick-detail.js";
|
|
19
|
+
import { rolePayload } from "../roles/role-view.js";
|
|
20
|
+
import { DIRECTOR_ROLE } from "../roles/roles.js";
|
|
21
|
+
import { readQueuedPromptBody } from "../inbox/inbox-edit.js";
|
|
22
|
+
import { intQuery, rejectBadQueueFileName, rejectBadRole, sinceMsQuery, windowDays } from "./gui-args.js";
|
|
23
|
+
import { sendJson } from "./http-body.js";
|
|
24
|
+
import { typoSuffix } from "../text/suggest.js";
|
|
25
|
+
/** Handle GET /api/transcript?role=<id>&n=N: rendered transcript lines for one loop's pi
|
|
26
|
+
* log (same rendering as `tumwater logs --role <id>`). Unknown/missing role or a bad n → 400.
|
|
27
|
+
* User-defined loops are valid targets too — the GUI marks them with an asterisk, so clicking
|
|
28
|
+
* one must open its transcript: ids validate through rejectBadRole. The 400 message lists
|
|
29
|
+
* exactly the ids accepted. The query arrives pre-parsed — the server parses the target
|
|
30
|
+
* once (gui/gui-server.ts's parseRequestTarget) and threads it down, so the parse idiom lives
|
|
31
|
+
* in one place and cannot disagree with the routing or the token gate about what the URL said. */
|
|
32
|
+
export function handleTranscript(q, res, root) {
|
|
33
|
+
const role = q.get("role");
|
|
34
|
+
if (rejectBadRole(root, res, role))
|
|
35
|
+
return;
|
|
36
|
+
const n = intQuery(q, res, "n", "positive", 50);
|
|
37
|
+
if (n === null)
|
|
38
|
+
return;
|
|
39
|
+
sendJson(res, 200, { lines: readTranscript(root, role, n) });
|
|
40
|
+
}
|
|
41
|
+
/** The /api/backlog `file` values and the open-section reader each addresses — one table so
|
|
42
|
+
* the on-demand dispatch and the "valid values" list in both 400s cannot drift apart. A Map,
|
|
43
|
+
* not an object literal, so no `file` spelling (`constructor`, `toString`, `__proto__`) can
|
|
44
|
+
* reach an inherited member through the lookup. Insertion order is the list order. */
|
|
45
|
+
const BACKLOG_FILE_READERS = new Map([
|
|
46
|
+
["plans", plannedPlanEntries],
|
|
47
|
+
["bugs", openBugEntries],
|
|
48
|
+
["questions", openQuestionEntries],
|
|
49
|
+
]);
|
|
50
|
+
const BACKLOG_FILE_NAMES = [...BACKLOG_FILE_READERS.keys()];
|
|
51
|
+
const BACKLOG_FILE_VALUES = BACKLOG_FILE_NAMES.join(", ");
|
|
52
|
+
/** Handle GET /api/backlog?file=<plans|bugs|questions>&index=N: one backlog entry's full
|
|
53
|
+
* text ({title, body}), fetched on demand so multi-KB bodies (long repros, whole plans) never
|
|
54
|
+
* ride the 1-second /api/status poll. index addresses the Nth entry of that file's open
|
|
55
|
+
* section in the same order statusPayload lists its titles — PLANS.md ## Planned,
|
|
56
|
+
* BUGS.md ## Open, QUESTIONS.md ## Open — zero-based. Unknown/missing file, missing or bad
|
|
57
|
+
* index, and out-of-range index → 400 JSON error via sendJson. The query arrives pre-parsed
|
|
58
|
+
* (gui/gui-server.ts's parseRequestTarget), like every GET-data handler here. */
|
|
59
|
+
export function handleBacklog(q, res, root) {
|
|
60
|
+
const file = q.get("file");
|
|
61
|
+
if (file === null) {
|
|
62
|
+
sendJson(res, 400, { error: `file required (valid values: ${BACKLOG_FILE_VALUES})` });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const reader = BACKLOG_FILE_READERS.get(file);
|
|
66
|
+
if (!reader) {
|
|
67
|
+
sendJson(res, 400, {
|
|
68
|
+
error: `unknown file ${JSON.stringify(file)} (valid values: ${BACKLOG_FILE_VALUES})${typoSuffix(file, BACKLOG_FILE_NAMES)}`,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const entries = reader(root);
|
|
73
|
+
const index = intQuery(q, res, "index", "non-negative");
|
|
74
|
+
if (index === null)
|
|
75
|
+
return;
|
|
76
|
+
const entry = entries[index];
|
|
77
|
+
if (!entry) {
|
|
78
|
+
sendJson(res, 400, { error: `index ${index} out of range (${file} has ${entries.length} open entries)` });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
sendJson(res, 200, { title: entry.title, body: entry.body });
|
|
82
|
+
}
|
|
83
|
+
/** Handle GET /api/diff[?role=<id>]: the unlanded-change view the CLI prints from the same
|
|
84
|
+
* collectors — no `role` → collectFleetChanges (exactly `tumwater diff --json`'s document:
|
|
85
|
+
* `{ mainBranch, roles }`, no per-role patch halves); `?role=<id>` → collectRoleChange (the
|
|
86
|
+
* full per-role view with `diff`/`uncommittedDiff`, `tumwater diff --role <id> --json`). The
|
|
87
|
+
* role is a target, not a filter, so it validates through rejectBadRole exactly like
|
|
88
|
+
* /api/transcript and /api/tick (unknown/missing → 400 naming the valid ids) — the same ids
|
|
89
|
+
* `parseRoleFlag` accepts on the CLI, including disabled and user-defined loops. The
|
|
90
|
+
* collectors degrade absent worktrees and missing base branches to their `state` instead of
|
|
91
|
+
* throwing, so a fresh or half-built repo still answers 200. Reads git plumbing directly, so
|
|
92
|
+
* it works whether or not the fleet is running. */
|
|
93
|
+
export async function handleDiff(q, res, root) {
|
|
94
|
+
const role = q.get("role");
|
|
95
|
+
if (role === null) {
|
|
96
|
+
sendJson(res, 200, await collectFleetChanges(root));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (rejectBadRole(root, res, role))
|
|
100
|
+
return;
|
|
101
|
+
sendJson(res, 200, await collectRoleChange(root, role));
|
|
102
|
+
}
|
|
103
|
+
/** Handle GET /api/report?days=N[&role=<id>] or /api/report?sinceMs=<ms>[&role=<id>]: the
|
|
104
|
+
* usage report — the day series (collectReport's ReportData) as JSON the dashboard's report tab
|
|
105
|
+
* charts, or, when sinceMs is set, the trailing-window totals rendered through
|
|
106
|
+
* renderSinceReportMarkdown as { markdown } (the CLI's `report --since` shape), bounded by the
|
|
107
|
+
* reportSinceMs helper. The day window follows windowDays. An absent role is the fleet-wide
|
|
108
|
+
* report exactly as before; a present one is a filter, validated through rejectBadRole (unknown,
|
|
109
|
+
* including the empty string → 400 naming the valid ids) and handed to the collector's role
|
|
110
|
+
* scope. Reads files directly, so it works whether or not the fleet is running. */
|
|
111
|
+
export function handleReport(q, res, root) {
|
|
112
|
+
const role = q.get("role");
|
|
113
|
+
if (role !== null && rejectBadRole(root, res, role))
|
|
114
|
+
return;
|
|
115
|
+
if (q.get("sinceMs") !== null) {
|
|
116
|
+
const sinceMs = reportSinceMs(q, res);
|
|
117
|
+
if (sinceMs === null)
|
|
118
|
+
return;
|
|
119
|
+
sendJson(res, 200, { markdown: renderSinceReportMarkdown(collectReportSince(root, sinceMs, role ?? undefined)) });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
sendJson(res, 200, collectReport(root, windowDays(q), role ?? undefined));
|
|
123
|
+
}
|
|
124
|
+
/** Parse and bound the report endpoints' `?sinceMs=` trailing-window length: a simultaneous
|
|
125
|
+
* `?days=` is the CLI's rival shape (whole local days vs a trailing window) → 400 naming both;
|
|
126
|
+
* otherwise the length rides sinceMsQuery (gui-args.ts) — positive-integer parse, then a 400
|
|
127
|
+
* naming the cap rather than a silent clamp (an explicit window typo must not shrink to some
|
|
128
|
+
* other window) — with the "must be between 1 and …" wording. Returns the parsed length, or
|
|
129
|
+
* null once the 400 is sent. */
|
|
130
|
+
function reportSinceMs(q, res) {
|
|
131
|
+
if (q.get("days") !== null) {
|
|
132
|
+
sendJson(res, 400, { error: "days and sinceMs are mutually exclusive; days counts whole local days, sinceMs totals a trailing window" });
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return sinceMsQuery(q, res, REPORT_SINCE_MAX_MS, (sinceMs) => `sinceMs must be between 1 and ${humanSeconds(REPORT_SINCE_MAX_MS / 1000)} (got ${sinceMs})`);
|
|
136
|
+
}
|
|
137
|
+
/** Handle GET /api/failures?days=N[&role=<id>] or /api/failures?sinceMs=<ms>[&role=<id>]: the
|
|
138
|
+
* same bounded Markdown failure digest the telemetry loop feeds on and `tumwater report
|
|
139
|
+
* --failures` prints, as JSON ({ markdown }) — the dashboard's failures tab renders it; with
|
|
140
|
+
* sinceMs it is the trailing-window digest (collectFailureReportSince) through the same render,
|
|
141
|
+
* the CLI's `report --failures --since` shape. The days window follows windowDays (so it can
|
|
142
|
+
* never drift from /api/report's). role rides the same absent-fleet / present-filter rule as
|
|
143
|
+
* handleReport: absent → the fleet digest, present → validated and scoped. Reads files
|
|
144
|
+
* directly, so it works with no fleet running. */
|
|
145
|
+
export function handleFailures(q, res, root) {
|
|
146
|
+
const role = q.get("role");
|
|
147
|
+
if (role !== null && rejectBadRole(root, res, role))
|
|
148
|
+
return;
|
|
149
|
+
if (q.get("sinceMs") !== null) {
|
|
150
|
+
const sinceMs = reportSinceMs(q, res);
|
|
151
|
+
if (sinceMs === null)
|
|
152
|
+
return;
|
|
153
|
+
sendJson(res, 200, { markdown: renderFailureMarkdown(collectFailureReportSince(root, sinceMs, role ?? undefined)) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sendJson(res, 200, { markdown: renderFailureMarkdown(collectFailureReport(root, windowDays(q), role ?? undefined)) });
|
|
157
|
+
}
|
|
158
|
+
/** Handle GET /api/history?role=<id>&n=N[&grep=<text>] — or ?role=<id>&sinceMs=<ms> for a
|
|
159
|
+
* trailing window: the per-tick rows the CLI's `tumwater history` derives from the event log,
|
|
160
|
+
* the dashboard's history tab renders them. The count shape ({ rows }) rides readTickRows (the
|
|
161
|
+
* shared scan, window growth included, so a role-filtered ask on a busy fleet cannot silently
|
|
162
|
+
* come back short); n is optional: absent → HISTORY_DEFAULT_TICKS; present but not a plain
|
|
163
|
+
* non-negative integer → 400 (the handleBacklog index discipline — an explicit count that does
|
|
164
|
+
* not parse is a client error, unlike the report's degrade-to-default typo rule); then clamped
|
|
165
|
+
* to [1, HISTORY_MAX_TICKS] — the GUI clamps where the CLI fails fast, its established
|
|
166
|
+
* convention. grep is optional: absent or empty filters nothing; present → the rows are
|
|
167
|
+
* filtered through history-data.ts's shared tickRowsMatchingGrep rule, the same case-insensitive haystack rule
|
|
168
|
+
* `tumwater history --grep` uses (shared, not re-implemented), after the n/role scan so n
|
|
169
|
+
* bounds the scanned window and not the printed rows — the CLI's rule. The window shape
|
|
170
|
+
* ({ rows, covered, note }) rides readTickRowsSince, the collector behind `history --since`,
|
|
171
|
+
* so the browser and the CLI cannot disagree about the window's rows; a simultaneous n is a
|
|
172
|
+
* rival shape → 400 (the CLI's count-vs-window rule), sinceMs parses and is capped through
|
|
173
|
+
* sinceMsQuery (gui-args.ts, LOGS_SINCE_MAX_MS — the shared `history --since` cap), and note is
|
|
174
|
+
* SPARSE_WINDOW_NOTE when covered is false, else "". role is optional: absent or empty → all
|
|
175
|
+
* loops; present → passed straight through as the role filter with no config validation (a
|
|
176
|
+
* filter is not a target — an id with no ticks legitimately yields zero rows), so the endpoint
|
|
177
|
+
* reads nothing but files, like /api/report and /api/failures. Reads files directly, so it
|
|
178
|
+
* works whether or not the fleet is running. */
|
|
179
|
+
export function handleHistory(q, res, root) {
|
|
180
|
+
const role = q.get("role") || null; // "" and absent both read all loops
|
|
181
|
+
const grep = q.get("grep") || null; // "" and absent both filter nothing
|
|
182
|
+
if (q.get("sinceMs") !== null) {
|
|
183
|
+
if (q.get("n") !== null) {
|
|
184
|
+
sendJson(res, 400, { error: "sinceMs cannot be combined with n (a count and a window are rival shapes)" });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const sinceMs = sinceMsQuery(q, res, LOGS_SINCE_MAX_MS, (ms) => `sinceMs is capped at ${humanSeconds(LOGS_SINCE_MAX_MS / 1000)} (got ${ms})`);
|
|
188
|
+
if (sinceMs === null)
|
|
189
|
+
return;
|
|
190
|
+
const { rows, covered } = readTickRowsSince(root, sinceMs, role);
|
|
191
|
+
const matched = grep === null ? rows : tickRowsMatchingGrep(rows, grep);
|
|
192
|
+
sendJson(res, 200, { rows: matched, covered, note: covered ? "" : SPARSE_WINDOW_NOTE });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const parsed = intQuery(q, res, "n", "non-negative", HISTORY_DEFAULT_TICKS);
|
|
196
|
+
if (parsed === null)
|
|
197
|
+
return;
|
|
198
|
+
const n = Math.min(HISTORY_MAX_TICKS, Math.max(1, parsed));
|
|
199
|
+
let rows = readTickRows(root, n, role);
|
|
200
|
+
if (grep !== null)
|
|
201
|
+
rows = tickRowsMatchingGrep(rows, grep);
|
|
202
|
+
sendJson(res, 200, { rows });
|
|
203
|
+
}
|
|
204
|
+
/** How many of the log's newest events `GET /api/logs` scans by default, and the most it will
|
|
205
|
+
* scan however large `n` asks: the CLI's `-n` shape (n bounds the scanned tail, not the printed
|
|
206
|
+
* rows), sized so the dashboard's search can reach well past the RECENT_EVENTS window the
|
|
207
|
+
* status poll carries. */
|
|
208
|
+
const LOGS_SEARCH_EVENTS = 500;
|
|
209
|
+
const LOGS_SEARCH_MAX_EVENTS = 5000;
|
|
210
|
+
/** Handle GET /api/logs?n=N[&grep=<text>][&role=<id>]: a slice of the raw event log scanned
|
|
211
|
+
* from its newest end, filtered with the CLI's own rules and shaped as the activity feed's
|
|
212
|
+
* items. `n` is optional (absent → LOGS_SEARCH_EVENTS) and clamps to [1, LOGS_SEARCH_MAX_EVENTS]
|
|
213
|
+
* after intQuery's non-negative parse (an explicit count that does not parse is a 400, the
|
|
214
|
+
* handleHistory discipline); it bounds the scanned tail, so a matching event older than the
|
|
215
|
+
* last n events is absent — the same scan-then-filter rule `tumwater logs -n N --grep` applies.
|
|
216
|
+
* grep (absent or empty filters nothing) matches through eventMatchesGrep, and role is a
|
|
217
|
+
* filter, not a target: an id with no events legitimately yields an empty list, so it does not
|
|
218
|
+
* validate through rejectBadRole (the handleHistory rule). The matched events answer
|
|
219
|
+
* oldest-first as eventFeedItem objects — the same shape the status payload's eventItems carry.
|
|
220
|
+
* Reads the tail through readEvents' stat-keyed cache (no covered byte end, unlike the CLI's
|
|
221
|
+
* follow), so a dashboard search that repeats on an unchanged log — the debounced filter fires
|
|
222
|
+
* one per filter change — costs one stat instead of re-reading and re-parsing the scanned tail.
|
|
223
|
+
* Reads files directly, so it works whether or not the fleet is running. */
|
|
224
|
+
export function handleLogs(q, res, root) {
|
|
225
|
+
const parsed = intQuery(q, res, "n", "non-negative", LOGS_SEARCH_EVENTS);
|
|
226
|
+
if (parsed === null)
|
|
227
|
+
return;
|
|
228
|
+
const n = Math.min(LOGS_SEARCH_MAX_EVENTS, Math.max(1, parsed));
|
|
229
|
+
const role = q.get("role") || null; // "" and absent both read all loops
|
|
230
|
+
const grep = q.get("grep") || null; // "" and absent both filter nothing
|
|
231
|
+
const needle = grep === null ? null : grep.toLowerCase();
|
|
232
|
+
const events = readEvents(root, n);
|
|
233
|
+
const matched = events.filter((e) => (role === null || eventRole(e) === role) && (needle === null || eventMatchesGrep(e, needle)));
|
|
234
|
+
sendJson(res, 200, { events: matched.map(eventFeedItem) });
|
|
235
|
+
}
|
|
236
|
+
/** Handle GET /api/tick?role=<id>&tick=N: one tick's full event trail — readTickDetail's
|
|
237
|
+
* TickDetail, the collector `tumwater tick <role> <n>` prints from, plus `text`: the same
|
|
238
|
+
* payload through renderTickDetail, pre-rendered server-side like /api/transcript's lines so
|
|
239
|
+
* the browser shows the CLI's exact rendering instead of re-implementing the event formatting.
|
|
240
|
+
* Serving the shared collector is the point: the CLI and this endpoint cannot drift on what one
|
|
241
|
+
* tick's block contains. The role is a target here, not a filter, so it validates through
|
|
242
|
+
* rejectBadRole (unknown/missing → 400 naming the valid ids); the tick is an explicit count
|
|
243
|
+
* through intQuery (missing or not a positive integer → 400); and a tick the scanned window
|
|
244
|
+
* does not hold — never ran, or rotation ate it — answers 404 with tickNotFoundMessage, the
|
|
245
|
+
* CLI's exact not-found wording,
|
|
246
|
+
* never a crash. Reads files directly, so it works whether or not the fleet is running. */
|
|
247
|
+
export function handleTick(q, res, root) {
|
|
248
|
+
const role = q.get("role");
|
|
249
|
+
if (rejectBadRole(root, res, role))
|
|
250
|
+
return;
|
|
251
|
+
const tick = intQuery(q, res, "tick", "positive");
|
|
252
|
+
if (tick === null)
|
|
253
|
+
return;
|
|
254
|
+
const detail = readTickDetail(root, role, tick);
|
|
255
|
+
if (detail === null) {
|
|
256
|
+
sendJson(res, 404, { error: tickNotFoundMessage(role, tick) });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
sendJson(res, 200, { ...detail, text: renderTickDetail(detail) });
|
|
260
|
+
}
|
|
261
|
+
/** Handle GET /api/role?role=<id>: one loop's inspection payload — rolePayload's object,
|
|
262
|
+
* the same collection behind `tumwater role <id>` — so the drawer's Notebook and Next tick
|
|
263
|
+
* prompt sections render what the CLI shows without re-implementing the assembly. The role
|
|
264
|
+
* is a target here, so it validates through rejectBadRole exactly like /api/transcript and
|
|
265
|
+
* /api/tick (unknown/missing → the shared 400 naming the valid ids and correcting typos);
|
|
266
|
+
* rolePayload is only reached with an id rejectBadRole accepted, so its own unknown-role
|
|
267
|
+
* throw never fires. Reads files directly, so it works whether or not the fleet is running. */
|
|
268
|
+
export function handleRole(q, res, root) {
|
|
269
|
+
const role = q.get("role");
|
|
270
|
+
if (rejectBadRole(root, res, role))
|
|
271
|
+
return;
|
|
272
|
+
sendJson(res, 200, rolePayload(root, role));
|
|
273
|
+
}
|
|
274
|
+
/** Handle GET /api/prompt-text?file=<name>[&role=<id>]: one queued prompt's editable body —
|
|
275
|
+
* the text an edit replaces, for the dashboard's per-row edit affordance (PLANS.md
|
|
276
|
+
* 2026-10-11). The prompt is addressed by its queue-file basename (the /api/prompt-cancel
|
|
277
|
+
* address), so a stale poll's snapshot can never read the wrong entry; the not-before deferral
|
|
278
|
+
* marker and the `[image attached: …]` reference block are stripped, both plumbing the edit
|
|
279
|
+
* carries over unchanged. The role is optional (absent reads the director's queue) and
|
|
280
|
+
* validates exactly like /api/transcript; a file name failing the shared
|
|
281
|
+
* rejectBadQueueFileName guard is a user-input error answered 400 with nothing read. A vanished file answers 200
|
|
282
|
+
* { ok: true, status: "gone" } — the loop already dequeued it, which is data for the UI, not
|
|
283
|
+
* a server fault. Reads one file directly, so it works whether or not the fleet is running. */
|
|
284
|
+
export function handlePromptText(q, res, root) {
|
|
285
|
+
const roleParam = q.get("role");
|
|
286
|
+
if (roleParam !== null && rejectBadRole(root, res, roleParam))
|
|
287
|
+
return;
|
|
288
|
+
const role = roleParam ?? DIRECTOR_ROLE;
|
|
289
|
+
const file = q.get("file");
|
|
290
|
+
if (rejectBadQueueFileName(res, file))
|
|
291
|
+
return;
|
|
292
|
+
const text = readQueuedPromptBody(root, role, file);
|
|
293
|
+
if (text === null) {
|
|
294
|
+
sendJson(res, 200, { ok: true, status: "gone" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
sendJson(res, 200, { ok: true, text });
|
|
298
|
+
}
|
|
299
|
+
/** Handle GET /api/doctor: the same DoctorReport `tumwater doctor --json` prints, so the
|
|
300
|
+
* Settings view's Health check card and the CLI cannot disagree. Read-only — the checks write
|
|
301
|
+
* nothing (runDoctor's own contract) — and it works whether or not the fleet is running. `run`
|
|
302
|
+
* exists so tests can skip the host probes; a throw reaches the server's outer 500. */
|
|
303
|
+
export async function handleDoctor(res, root, run = runDoctor) {
|
|
304
|
+
sendJson(res, 200, await run(root));
|
|
305
|
+
}
|
|
306
|
+
/** Handle GET /api/config: the Settings view's resolved values for exactly
|
|
307
|
+
* EDITABLE_CONFIG_KEYS — read through the same load path `tumwater config get` uses
|
|
308
|
+
* (loadConfigSafe, defaults merged in), so the page shows what the fleet would actually
|
|
309
|
+
* load. A broken or invalid tumwater.json answers 500 with validateConfig's message instead
|
|
310
|
+
* of serving a half-empty panel. */
|
|
311
|
+
export function handleConfig(res, root) {
|
|
312
|
+
const { config, error } = loadConfigSafe(root);
|
|
313
|
+
if (config === undefined) {
|
|
314
|
+
sendJson(res, 500, { error });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const record = config;
|
|
318
|
+
const out = {};
|
|
319
|
+
for (const key of EDITABLE_CONFIG_KEYS)
|
|
320
|
+
out[key] = record[key] ?? null;
|
|
321
|
+
sendJson(res, 200, out);
|
|
322
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/** The dashboard HTTP server: routing, the shared-token gate, the static page, /api/status, and the
|
|
2
|
+
* self-reload watch. Every /api endpoint's handler lives in gui/gui-endpoints.ts (the GET data
|
|
3
|
+
* endpoints) and gui/gui-endpoint-commands.ts (the POST operator endpoints) — this module owns
|
|
4
|
+
* the socket, not the data. The `tumwater gui` CLI entry (cmdGui) lives in gui/gui-command.ts,
|
|
5
|
+
* which imports startGui from here, so the server and the command that boots it stay adjacent. */
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import { GUI_PAGE } from "../ui/gui/gui-page.js";
|
|
9
|
+
import { statusPayload } from "../ui/status-payload.js";
|
|
10
|
+
import { captureStartupBuild, createReloadWatch, reexecSelf, watchReloadSupervisor, } from "../redeploy/self-reload.js";
|
|
11
|
+
import { warnEvent } from "../events/events.js";
|
|
12
|
+
import { errorMessage } from "../text/text.js";
|
|
13
|
+
import { handleBacklog, handleConfig, handleDiff, handleFailures, handleReport, handleHistory, handleLogs, handlePromptText, handleTick, handleRole, handleTranscript, handleDoctor, } from "./gui-endpoints.js";
|
|
14
|
+
import { handleAbort, handleBudget, handleConfigSet, handleConfigUnset, handlePause, handlePauseRole, handlePrompt, handleBacklogFile, handleBacklogCancel, handleBacklogEdit, handlePromptCancel, handlePromptEdit, handlePromptRole, handleQuestionAnswer, handleReclaim, handleRestart, handleResetCounters, handleRoleNotes, handleWake, handleWakeCancel, } from "./gui-endpoint-commands.js";
|
|
15
|
+
import { sendJson } from "./http-body.js";
|
|
16
|
+
/** Constant-time credential comparison for the shared-token gate: `timingSafeEqual` throws
|
|
17
|
+
* on unequal lengths, so the length equality is the guard. A naive `===` string compare
|
|
18
|
+
* would leak the token's length and prefix byte by byte. */
|
|
19
|
+
function tokenMatches(expected, provided) {
|
|
20
|
+
const a = Buffer.from(expected, "utf8");
|
|
21
|
+
const b = Buffer.from(provided, "utf8");
|
|
22
|
+
if (a.length !== b.length)
|
|
23
|
+
return false;
|
|
24
|
+
return crypto.timingSafeEqual(a, b);
|
|
25
|
+
}
|
|
26
|
+
/** The request target parsed once (path, query, and the token gate all read the same
|
|
27
|
+
* object), or null when `req.url` is not a parseable URL — such requests fall through to
|
|
28
|
+
* 404 instead of throwing into the handler's 500 catch, token gate included: the gate reads
|
|
29
|
+
* the same parsed target rather than re-parsing, so a malformed target answers 401 there
|
|
30
|
+
* like the 404 it earns on the token-less server, never a 500 for a bad request.
|
|
31
|
+
* Routing compares the exact pathname, never the raw target: a startsWith on req.url
|
|
32
|
+
* answered ANY path prefixing a route (e.g. /api/transcripts?role=… returned 200 transcript
|
|
33
|
+
* data), and an equality check on the raw target would miss query strings on exact routes
|
|
34
|
+
* (/api/status?x → 404). */
|
|
35
|
+
function parseRequestTarget(req) {
|
|
36
|
+
try {
|
|
37
|
+
return new URL(req.url ?? "", "http://localhost");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // Unparseable target — not a route.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** True when a request's Origin header names a server other than this one — the browser's
|
|
44
|
+
* cross-origin signal. Browsers attach Origin to every cross-site POST (fetch and form
|
|
45
|
+
* alike) and to same-origin POSTs too, so a POST from the dashboard carries this server's
|
|
46
|
+
* own Host authority, while curl and scripts send no Origin at all. `Origin: null`
|
|
47
|
+
* (sandboxed frames, data: redirects) is the hostile shape and is refused too. Pure header
|
|
48
|
+
* reading, so the router can gate every POST route through it in one place. */
|
|
49
|
+
function crossOriginRequest(req) {
|
|
50
|
+
const raw = req.headers.origin;
|
|
51
|
+
if (raw === undefined)
|
|
52
|
+
return false; // no Origin: not a browser — allow
|
|
53
|
+
const origin = Array.isArray(raw) ? raw[0] : raw;
|
|
54
|
+
if (origin === "null")
|
|
55
|
+
return true;
|
|
56
|
+
try {
|
|
57
|
+
return new URL(origin).host.toLowerCase() !== (req.headers.host ?? "").toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return true; // unparseable Origin — refuse a signal we cannot verify
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The address `startGui` binds for a given `allInterfaces` choice: loopback-only by default,
|
|
64
|
+
* the unspecified address (`undefined` — every interface, IPv4 and IPv6) on request. Pure and
|
|
65
|
+
* host-independent, so the mapping is testable without a live socket or this host's address
|
|
66
|
+
* family. */
|
|
67
|
+
export function guiBindHost(allInterfaces) {
|
|
68
|
+
return allInterfaces ? undefined : "127.0.0.1";
|
|
69
|
+
}
|
|
70
|
+
/** Whether a request's `Host` header names the loopback-bound server itself — `localhost`,
|
|
71
|
+
* `127.0.0.1`, or `::1`, each with or without its port (`[::1]:7180` bracketed). The check
|
|
72
|
+
* exists for a loopback-bound server only (see startGui): a browser that resolves an
|
|
73
|
+
* attacker-controlled name to 127.0.0.1 (DNS rebinding) reaches a loopback socket while its
|
|
74
|
+
* page's origin and the request's Host both name the attacker's domain, so crossOriginRequest's
|
|
75
|
+
* Origin/Host comparison calls the forged request same-origin. The Host is the one header the
|
|
76
|
+
* rebinding browser must set to that domain, so refusing a non-loopback Host closes both the
|
|
77
|
+
* read endpoints and the state-changing POSTs to a server reachable only from this machine.
|
|
78
|
+
* A non-bracketed IPv6 literal is malformed per RFC 7230 and is not accepted. Pure, so the
|
|
79
|
+
* parsing is testable without a socket. */
|
|
80
|
+
export function loopbackHostAllowed(hostHeader) {
|
|
81
|
+
let host = hostHeader.trim().toLowerCase();
|
|
82
|
+
if (host.startsWith("[")) {
|
|
83
|
+
const end = host.indexOf("]");
|
|
84
|
+
if (end === -1)
|
|
85
|
+
return false;
|
|
86
|
+
host = host.slice(1, end);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const colon = host.lastIndexOf(":");
|
|
90
|
+
if (colon !== -1)
|
|
91
|
+
host = host.slice(0, colon);
|
|
92
|
+
}
|
|
93
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
94
|
+
}
|
|
95
|
+
/** Record a post-listen dashboard server error without letting the recording itself take the
|
|
96
|
+
* dashboard down. warnEvent appends to the event log, which can fail (ENOSPC, EACCES under
|
|
97
|
+
* disk pressure); a throw out of a Node 'error' listener is an uncaught exception that kills
|
|
98
|
+
* the process, turning a recoverable socket error into a dead dashboard — the opposite of this
|
|
99
|
+
* handler's purpose. The warning is best-effort, the same policy the cleanup warn sites apply. */
|
|
100
|
+
function recordServerError(root, err) {
|
|
101
|
+
try {
|
|
102
|
+
warnEvent(root, "harness", `dashboard server error: ${errorMessage(err)}`);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// The log is another process's to fix; the dashboard stays up either way.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Start the dashboard server. Binds to 127.0.0.1 by default; with `allInterfaces` it
|
|
109
|
+
* binds the unspecified address (every interface, IPv4 and IPv6), making the dashboard —
|
|
110
|
+
* including the director prompt box, which anyone reaching it can use to steer the fleet —
|
|
111
|
+
* available to the whole network. There is no authentication by default — exposing it is
|
|
112
|
+
* the caller's deliberate choice — and an optional shared token (`gui --token <secret>`,
|
|
113
|
+
* passed as `token`) gates every route: requests must carry it as `Authorization: Bearer
|
|
114
|
+
* <token>` or `?token=`, anything else gets a 401 JSON error. An empty token means no
|
|
115
|
+
* check — byte-for-byte the open server. Resolves once it is listening.
|
|
116
|
+
*
|
|
117
|
+
* `watch` overrides the self-reload watch's seams (self-reload.ts's injectables) plus the
|
|
118
|
+
* re-exec itself, and the supervised child's supervisor watch plus its exit; production
|
|
119
|
+
* callers omit it and get the real disk-stamp poll, reexecSelf and process.exit — tests
|
|
120
|
+
* inject fakes so the wiring (close, then re-exec, at most once; close, then exit, once the
|
|
121
|
+
* reload supervisor is gone) is assertable without launching a process or touching this
|
|
122
|
+
* dist's own stamp. */
|
|
123
|
+
export function startGui(root, port, allInterfaces = false, token = "", watch = {}) {
|
|
124
|
+
// The serving process's own startup stamp: added to every /api/status payload so the page can
|
|
125
|
+
// notice a newer server (a redeploy or manual build re-execs this process) and reload itself.
|
|
126
|
+
const startupBuild = captureStartupBuild();
|
|
127
|
+
// The routes as one table, keyed by `METHOD path`: dispatch is a single lookup where the
|
|
128
|
+
// previous twenty-branch if/else chain restated `req.method === … && pathname === …` on
|
|
129
|
+
// every arm. Each path appears once (the page's two spellings share servePage), so lookup
|
|
130
|
+
// order carries no meaning. A handler that needs the query reads the one parsed target
|
|
131
|
+
// (parseRequestTarget) — routing matched its exact pathname, so the target is non-null.
|
|
132
|
+
const servePage = (_req, res) => {
|
|
133
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
134
|
+
res.end(GUI_PAGE);
|
|
135
|
+
};
|
|
136
|
+
const guiRoutes = {
|
|
137
|
+
"GET /": servePage,
|
|
138
|
+
"GET /index.html": servePage,
|
|
139
|
+
"GET /api/status": (_req, res) => {
|
|
140
|
+
sendJson(res, 200, { ...statusPayload(root), serverBuildSha: startupBuild?.sha ?? null });
|
|
141
|
+
},
|
|
142
|
+
"GET /api/report": (_req, res, target) => handleReport(target.searchParams, res, root),
|
|
143
|
+
"GET /api/failures": (_req, res, target) => handleFailures(target.searchParams, res, root),
|
|
144
|
+
"GET /api/history": (_req, res, target) => handleHistory(target.searchParams, res, root),
|
|
145
|
+
"GET /api/logs": (_req, res, target) => handleLogs(target.searchParams, res, root),
|
|
146
|
+
"GET /api/transcript": (_req, res, target) => handleTranscript(target.searchParams, res, root),
|
|
147
|
+
"GET /api/prompt-text": (_req, res, target) => handlePromptText(target.searchParams, res, root),
|
|
148
|
+
"GET /api/tick": (_req, res, target) => handleTick(target.searchParams, res, root),
|
|
149
|
+
"GET /api/role": (_req, res, target) => handleRole(target.searchParams, res, root),
|
|
150
|
+
"GET /api/diff": (_req, res, target) => handleDiff(target.searchParams, res, root),
|
|
151
|
+
"GET /api/backlog": (_req, res, target) => handleBacklog(target.searchParams, res, root),
|
|
152
|
+
"GET /api/config": (_req, res) => handleConfig(res, root),
|
|
153
|
+
"GET /api/doctor": (_req, res) => handleDoctor(res, root),
|
|
154
|
+
"POST /api/config-set": (req, res) => handleConfigSet(req, res, root),
|
|
155
|
+
"POST /api/config-unset": (req, res) => handleConfigUnset(req, res, root),
|
|
156
|
+
"POST /api/prompt": (req, res) => handlePrompt(req, res, root),
|
|
157
|
+
"POST /api/prompt-role": (req, res) => handlePromptRole(req, res, root),
|
|
158
|
+
"POST /api/backlog-file": (req, res) => handleBacklogFile(req, res, root),
|
|
159
|
+
"POST /api/backlog-cancel": (req, res) => handleBacklogCancel(req, res, root),
|
|
160
|
+
"POST /api/backlog-edit": (req, res) => handleBacklogEdit(req, res, root),
|
|
161
|
+
"POST /api/prompt-cancel": (req, res) => handlePromptCancel(req, res, root),
|
|
162
|
+
"POST /api/prompt-edit": (req, res) => handlePromptEdit(req, res, root),
|
|
163
|
+
"POST /api/question-answer": (req, res) => handleQuestionAnswer(req, res, root),
|
|
164
|
+
"POST /api/budget": (req, res) => handleBudget(req, res, root),
|
|
165
|
+
"POST /api/pause": (req, res) => handlePause(req, res, root),
|
|
166
|
+
"POST /api/wake": (req, res) => handleWake(req, res, root),
|
|
167
|
+
"POST /api/wake-cancel": (req, res) => handleWakeCancel(req, res, root),
|
|
168
|
+
"POST /api/reset-counters": (req, res) => handleResetCounters(req, res, root),
|
|
169
|
+
"POST /api/role-notes": (req, res) => handleRoleNotes(req, res, root),
|
|
170
|
+
"POST /api/restart": (req, res) => handleRestart(req, res, root),
|
|
171
|
+
"POST /api/reclaim": (req, res) => handleReclaim(req, res, root),
|
|
172
|
+
"POST /api/abort": (req, res) => handleAbort(req, res, root),
|
|
173
|
+
"POST /api/pause-role": (req, res) => handlePauseRole(req, res, root),
|
|
174
|
+
};
|
|
175
|
+
const server = http.createServer(async (req, res) => {
|
|
176
|
+
try {
|
|
177
|
+
const target = parseRequestTarget(req);
|
|
178
|
+
const pathname = target?.pathname ?? null;
|
|
179
|
+
// DNS-rebinding gate, ahead of every route on a loopback-bound server: a hostile page
|
|
180
|
+
// whose domain resolves to 127.0.0.1 is same-origin with this server, so the
|
|
181
|
+
// cross-origin gate below cannot tell its requests from the dashboard's — but its
|
|
182
|
+
// Host header must name the attacker's domain, which a request to the loopback
|
|
183
|
+
// dashboard never legitimately does. Refusing that Host blocks the read endpoints
|
|
184
|
+
// (status, config, transcripts) and the state-changing POSTs together. An
|
|
185
|
+
// all-interfaces server is deliberately network-facing, so it keeps accepting every
|
|
186
|
+
// Host (the operator's exposure choice); a token, if set, still gates it.
|
|
187
|
+
if (!allInterfaces && !loopbackHostAllowed(req.headers.host ?? "")) {
|
|
188
|
+
sendJson(res, 403, {
|
|
189
|
+
error: "request Host is not this loopback dashboard's — a rebinding name is refused",
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Opt-in shared-token gate, ahead of every route: with a token set, the page and all
|
|
194
|
+
// /api endpoints require it as `Authorization: Bearer <token>` or `?token=`; anything
|
|
195
|
+
// else — including `GET /` — gets the same JSON 401 every handler uses, deliberately
|
|
196
|
+
// no HTML login form: the CLI prints the token-bearing URL, and a bare 401 body is the
|
|
197
|
+
// honest signal that the URL needs `?token=`.
|
|
198
|
+
if (token) {
|
|
199
|
+
const auth = req.headers.authorization ?? "";
|
|
200
|
+
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
|
|
201
|
+
const provided = bearer || target?.searchParams.get("token") || "";
|
|
202
|
+
if (!tokenMatches(token, provided)) {
|
|
203
|
+
sendJson(res, 401, { error: "token required" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Cross-origin gate, ahead of every state-changing route: a hostile webpage can POST
|
|
208
|
+
// to the open localhost server with a CORS-safelisted text/plain body (no preflight,
|
|
209
|
+
// no readable response) and the side effect alone is the payload — a forged director
|
|
210
|
+
// prompt, a fleet pause, an abort. Browsers mark every cross-site POST with an Origin
|
|
211
|
+
// naming the hostile server, so one header check here refuses the forgery while the
|
|
212
|
+
// dashboard's own POSTs (same-host Origin) and non-browser clients (no Origin) pass.
|
|
213
|
+
if (req.method === "POST" && crossOriginRequest(req)) {
|
|
214
|
+
sendJson(res, 403, {
|
|
215
|
+
error: "cross-origin request rejected — dashboard POSTs must come from this dashboard's own origin",
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const route = guiRoutes[`${req.method ?? ""} ${pathname ?? ""}`];
|
|
220
|
+
if (route)
|
|
221
|
+
await route(req, res, target);
|
|
222
|
+
else {
|
|
223
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
224
|
+
res.end("not found");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
sendJson(res, 500, { error: errorMessage(err) });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
// Reload onto a newer compiled tree: close the server, then re-exec. The page's own poll sees
|
|
232
|
+
// the new process's changed serverBuildSha and reloads; failed polls while the port is down
|
|
233
|
+
// are swallowed by its existing catch.
|
|
234
|
+
const reloadWatch = createReloadWatch({
|
|
235
|
+
root,
|
|
236
|
+
startupInfo: startupBuild,
|
|
237
|
+
...watch,
|
|
238
|
+
onTrigger: () => {
|
|
239
|
+
server.close();
|
|
240
|
+
(watch.reexec ?? reexecSelf)();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
void reloadWatch.start();
|
|
244
|
+
// A reloaded child whose reload supervisor died outright: nothing will respawn or stop it,
|
|
245
|
+
// so it frees the port and exits instead of serving on at PPID 1.
|
|
246
|
+
const stopSupervisorWatch = watchReloadSupervisor(() => {
|
|
247
|
+
reloadWatch.stop();
|
|
248
|
+
server.close();
|
|
249
|
+
(watch.exit ?? process.exit)(0);
|
|
250
|
+
}, watch.supervisor);
|
|
251
|
+
server.once("close", stopSupervisorWatch);
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
const onListenError = (err) => reject(err);
|
|
254
|
+
server.once("error", onListenError);
|
|
255
|
+
const onListening = () => {
|
|
256
|
+
// The listen-time listener must not outlive the listen: an 'error' after this point
|
|
257
|
+
// would call reject on a settled promise and be swallowed. Replace it with a handler
|
|
258
|
+
// that records the failure, so a post-listen socket error (EMFILE on accept, …) is
|
|
259
|
+
// visible in the event feed instead of silently leaving a dead dashboard.
|
|
260
|
+
server.removeListener("error", onListenError);
|
|
261
|
+
server.on("error", (err) => recordServerError(root, err));
|
|
262
|
+
resolve(server);
|
|
263
|
+
};
|
|
264
|
+
const host = guiBindHost(allInterfaces);
|
|
265
|
+
if (host === undefined)
|
|
266
|
+
server.listen(port, onListening);
|
|
267
|
+
else
|
|
268
|
+
server.listen(port, host, onListening);
|
|
269
|
+
});
|
|
270
|
+
}
|