tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -1,57 +1,27 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
/** Harness-mediated config WRITES — the mutators behind the operator surfaces (the TUI's
|
|
2
3
|
* Ctrl+B editor, the GUI's /api/budget endpoint) and the director's config requests. Each
|
|
3
4
|
* one loads fresh (bypassing config.ts's stat cache), validates before writing, and writes
|
|
4
5
|
* atomically, because readers poll tumwater.json every ~2 s. The read side — defaults,
|
|
5
|
-
* loading, saving, and the role-selection helpers — lives in config.ts,
|
|
6
|
-
* model derivations in config-views.ts
|
|
6
|
+
* loading, saving, and the role-selection helpers — lives in config.ts, the per-run
|
|
7
|
+
* model derivations in config-views.ts, and the config KEY language — how keys are parsed,
|
|
8
|
+
* addressed and given per-key value screeners — lives in config-keys.ts (both the write
|
|
9
|
+
* verbs here and the read side / doctor share it). */
|
|
7
10
|
import fs from "node:fs";
|
|
8
|
-
import {
|
|
11
|
+
import { modelTierMap } from "./config-schema.js";
|
|
9
12
|
import { configPath, configLockPath, configRequestPath, CONFIG_BASENAME } from "../paths.js";
|
|
10
13
|
import { errorMessage } from "../text/text.js";
|
|
11
|
-
import { typoSuffix } from "../text/suggest.js";
|
|
12
14
|
import { writeJsonAtomic } from "../files/json-files.js";
|
|
13
15
|
import { isJsonObject, objectOrEmpty } from "../files/json-object.js";
|
|
14
16
|
import { withStateLock } from "../concurrency/lock.js";
|
|
15
17
|
import { show } from "./config-field-checks.js";
|
|
16
18
|
import { validateConfig } from "./config-validation.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* 0 disables the gate, fractional dollars allowed (the badge renders cents). Returns an
|
|
22
|
-
* actionable error message for anything else so both surfaces can flash it without
|
|
23
|
-
* try/catch plumbing. A value past Number.MAX_SAFE_INTEGER is rejected too: it stays
|
|
24
|
-
* finite ("9"×25 → 1e24), so only this screen stands between a one-zero typo in the
|
|
25
|
-
* browser's budget editor and an effectively uncapped budget (BUGS.md 2026-10-02, the
|
|
26
|
-
* same rule the TUI's parseBudgetInput applies). `key` names the config key in the error,
|
|
27
|
-
* defaulting to the fleet-wide `maxDailyCostUsd`; the per-role map passes
|
|
28
|
-
* `maxDailyCostUsdPerRole.<id>` so a rejected entry names the key actually set. */
|
|
29
|
-
export function checkDailyBudgetUsd(value, key = "maxDailyCostUsd") {
|
|
30
|
-
if (typeof value !== "number" ||
|
|
31
|
-
!Number.isFinite(value) ||
|
|
32
|
-
value < 0 ||
|
|
33
|
-
value > Number.MAX_SAFE_INTEGER)
|
|
34
|
-
return `${key} must be a number of 0 or more, at most ${Number.MAX_SAFE_INTEGER}, 0 disables (got ${show(value)})`;
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
/** One definition of "a valid quietHours window" (src/scheduling/quiet-hours.ts owns the format):
|
|
38
|
-
* "HH:MM-HH:MM" in local time, wrapping permitted, empty means off. parseQuietHours's
|
|
39
|
-
* message is the one wording every surface (validateConfig, `config set`, the TUI/GUI
|
|
40
|
-
* editors) shows, so they cannot drift apart on what a valid window is. `key` names the
|
|
41
|
-
* config key in the error, defaulting to the fleet-wide `quietHours`; the per-role map passes
|
|
42
|
-
* `quietHoursPerRole.<id>` so a rejected entry names the key actually set. */
|
|
43
|
-
export function checkQuietHours(value, key = "quietHours") {
|
|
44
|
-
const parsed = parseQuietHours(value, key);
|
|
45
|
-
return parsed.ok ? null : parsed.error;
|
|
46
|
-
}
|
|
47
|
-
/** Per-key value validators `config set` (and the dashboard editors that call it) run
|
|
48
|
-
* BEFORE the write, so a malformed value fails with its own actionable message instead of
|
|
49
|
-
* waiting for validateConfig's whole-file report. */
|
|
50
|
-
const PER_KEY_VALIDATORS = {
|
|
51
|
-
quietHours: checkQuietHours,
|
|
52
|
-
};
|
|
19
|
+
import { canonicalConfigKey, normalizeConfigKeys } from "./config-aliases.js";
|
|
20
|
+
import { unknownRoleMessage } from "../roles/roles.js";
|
|
21
|
+
import { loadConfig, parseJsonConfig, collapseAgentAliases, knownRoleIdsCached } from "./config.js";
|
|
22
|
+
import { checkDailyBudgetUsd, checkProviderConcurrencyCap, checkQuietHours, parseConfigKey, PER_KEY_VALIDATORS, unknownConfigKeyError, unknownRoleFieldError, } from "./config-keys.js";
|
|
53
23
|
/** Serialize a config write through the cross-process lock (paths.ts's configLockPath): the
|
|
54
|
-
* CLI, a standalone dashboard's server, and the running
|
|
24
|
+
* CLI, a standalone dashboard's server, and the running team are separate processes, so an
|
|
55
25
|
* unguarded read-modify-write lets one writer's whole-file write silently drop another's change
|
|
56
26
|
* (the same lost update paused-roles.json already guards against). A lock that cannot be
|
|
57
27
|
* acquired within the budget throws rather than writing unlocked — a silently lost config edit
|
|
@@ -62,6 +32,12 @@ function withConfigLock(root, fn) {
|
|
|
62
32
|
// file).
|
|
63
33
|
return withStateLock(configLockPath(root), fn, CONFIG_LOCK_TIMEOUT_MS);
|
|
64
34
|
}
|
|
35
|
+
/** The error arm of the shared write-result union — one home for the `{ ok: false, error }`
|
|
36
|
+
* constructor every writer's failure path returns (a broken on-disk file, a validation
|
|
37
|
+
* problem, a failed write, a lost lock), so the shape cannot drift between callers. */
|
|
38
|
+
function fail(error) {
|
|
39
|
+
return { ok: false, error };
|
|
40
|
+
}
|
|
65
41
|
/** The one load → mutate → validate → atomic-write idiom every top-level config writer
|
|
66
42
|
* shares (setDailyBudgetUsd, setConfigKey): a fresh loadConfig that bypasses config.ts's
|
|
67
43
|
* stat cache (a writer must see the latest file), the caller's mutation applied in memory,
|
|
@@ -80,7 +56,7 @@ function writeConfigMutation(root, mutate) {
|
|
|
80
56
|
cfg = loadConfig(root); // fresh — bypasses the stat cache on purpose
|
|
81
57
|
}
|
|
82
58
|
catch (err) {
|
|
83
|
-
return
|
|
59
|
+
return fail(errorMessage(err)); // broken file: never overwrite it with defaults
|
|
84
60
|
}
|
|
85
61
|
const mutated = mutate(cfg);
|
|
86
62
|
// An unset that finds nothing to remove returns the loaded config unchanged. Write nothing:
|
|
@@ -96,20 +72,20 @@ function writeConfigMutation(root, mutate) {
|
|
|
96
72
|
validateConfig(candidate); // throws listing every problem — nothing is written on failure
|
|
97
73
|
}
|
|
98
74
|
catch (err) {
|
|
99
|
-
return
|
|
75
|
+
return fail(errorMessage(err));
|
|
100
76
|
}
|
|
101
77
|
try {
|
|
102
78
|
// The trailing newline is tumwater.json's convention (POSIX text file).
|
|
103
79
|
writeJsonAtomic(configPath(root), candidate, true);
|
|
104
80
|
}
|
|
105
81
|
catch (err) {
|
|
106
|
-
return
|
|
82
|
+
return fail(errorMessage(err));
|
|
107
83
|
}
|
|
108
84
|
return { ok: true };
|
|
109
85
|
});
|
|
110
86
|
}
|
|
111
87
|
catch (err) {
|
|
112
|
-
return
|
|
88
|
+
return fail(errorMessage(err)); // lock acquire timeout/failure — nothing written
|
|
113
89
|
}
|
|
114
90
|
}
|
|
115
91
|
/** Set the daily cost budget cap in tumwater.json through the shared writer idiom (see
|
|
@@ -118,7 +94,7 @@ function writeConfigMutation(root, mutate) {
|
|
|
118
94
|
export function setDailyBudgetUsd(root, value) {
|
|
119
95
|
const problem = checkDailyBudgetUsd(value);
|
|
120
96
|
if (problem)
|
|
121
|
-
return
|
|
97
|
+
return fail(problem);
|
|
122
98
|
return writeConfigMutation(root, (cfg) => {
|
|
123
99
|
const next = { ...cfg, maxDailyCostUsd: value };
|
|
124
100
|
// A whole-config write: keep the file's own roles section (see preserveFileRoles).
|
|
@@ -126,83 +102,6 @@ export function setDailyBudgetUsd(root, value) {
|
|
|
126
102
|
return next;
|
|
127
103
|
});
|
|
128
104
|
}
|
|
129
|
-
/** The per-role maps `config get/set` reaches with dotted keys: each names ONE entry
|
|
130
|
-
* (`maxDailyCostUsdPerRole.<role>`, `quietHoursPerRole.<role>`) that merges into the
|
|
131
|
-
* existing map, so raising one role's cap never requires re-typing another's. The per-key
|
|
132
|
-
* value validator runs before the write, the same screening a bare top-level key gets. */
|
|
133
|
-
const DOTTED_MAP_KEYS = ["maxDailyCostUsdPerRole", "quietHoursPerRole"];
|
|
134
|
-
/** The valid first segments of a dotted config key — the per-role maps plus the `roles.` and
|
|
135
|
-
* `model.` sections. The generic dotted-shape error suggests against these so a typo'd head
|
|
136
|
-
* (`modle.small`, `maxDailyCostUsdPerRoll.feature`) gets the same did-you-mean the sibling
|
|
137
|
-
* unknown-key errors attach. */
|
|
138
|
-
const DOTTED_KEY_HEADS = [...DOTTED_MAP_KEYS, "roles", "model", "agents"];
|
|
139
|
-
/** Split a config key into its write/get shape: `foo` is top-level,
|
|
140
|
-
* `maxDailyCostUsdPerRole.feature` / `quietHoursPerRole.qa` name one map entry,
|
|
141
|
-
* `roles.qa.model` names one role-entry field, and anything else dotted is an error
|
|
142
|
-
* naming the supported dotted forms. */
|
|
143
|
-
export function parseConfigKey(key) {
|
|
144
|
-
if (!key.includes("."))
|
|
145
|
-
return { kind: "top", key };
|
|
146
|
-
const parts = key.split(".");
|
|
147
|
-
const first = parts[0] ?? "";
|
|
148
|
-
const second = parts.length > 1 ? parts[1] ?? "" : undefined;
|
|
149
|
-
const third = parts.length > 2 ? parts[2] ?? "" : undefined;
|
|
150
|
-
if (third === undefined && second !== undefined && second !== "" && DOTTED_MAP_KEYS.includes(first))
|
|
151
|
-
return { kind: "map", map: first, role: second };
|
|
152
|
-
if (parts.length === 3 && first === "roles" && second !== undefined && second !== "" && third !== undefined && third !== "")
|
|
153
|
-
return { kind: "role", id: second, field: third };
|
|
154
|
-
// `agents.<id>.<field>` names one field of one agent backend's entry (the way
|
|
155
|
-
// `roles.<id>.<field>` names one role field). The id is shape-checked only — routing owns
|
|
156
|
-
// which agent ids exist (part 4b); a typo'd field is refused here with the valid ones named.
|
|
157
|
-
if (parts.length === 3 && first === "agents" && second !== undefined && second !== "" && third !== undefined) {
|
|
158
|
-
if (AGENT_KEYS.includes(third))
|
|
159
|
-
return { kind: "agent", id: second, field: third };
|
|
160
|
-
return {
|
|
161
|
-
kind: "error",
|
|
162
|
-
error: `unknown agent field "${third}" for agents.${second} (valid fields: ${AGENT_KEYS.join(", ")})${typoSuffix(third, AGENT_KEYS)}`,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
// `model.<tier>` names one entry of the tier model map — the way `roles.<id>.model`
|
|
166
|
-
// names one role field, `maxDailyCostUsdPerRole.<role>` names one map entry. The tier
|
|
167
|
-
// must be a real tier: `config set model.turbo x` would otherwise write a dead map key
|
|
168
|
-
// the runtime (and doctor's checkKnownKeys) would silently never resolve.
|
|
169
|
-
if (parts.length === 2 && first === "model") {
|
|
170
|
-
if (TIER_MAP_KEYS.includes(second ?? ""))
|
|
171
|
-
return { kind: "tier", map: "model", tier: second };
|
|
172
|
-
return {
|
|
173
|
-
kind: "error",
|
|
174
|
-
error: `unknown model tier "${second ?? ""}" — a dotted \`model.<tier>\` names one of ${TIER_MAP_KEYS.join(", ")} (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`)${typoSuffix(second ?? "", TIER_MAP_KEYS)}`,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
// A typo'd first segment is the common miss (`maxDailyCostUsdPerRoll.feature`,
|
|
178
|
-
// `modle.small`); when the head is one of the valid ones the shape itself is wrong, so
|
|
179
|
-
// suggesting that head back would mislead — the generic wording is the whole story there.
|
|
180
|
-
const headHint = DOTTED_KEY_HEADS.includes(first)
|
|
181
|
-
? ""
|
|
182
|
-
: typoSuffix(first, DOTTED_KEY_HEADS);
|
|
183
|
-
return {
|
|
184
|
-
kind: "error",
|
|
185
|
-
error: `unknown config key "${key}" — dotted keys name one entry of a per-role map or one field of a role entry (e.g. \`maxDailyCostUsdPerRole.feature 1.5\`, \`roles.qa.model x\`); a bare key names a whole top-level value${headHint}`,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
/** The error `tumwater config get <key>` and `config set <key> <value>` both surface for a
|
|
189
|
-
* key outside TOP_LEVEL_KEYS — the valid-keys list plus text.ts's did-you-mean suggestion —
|
|
190
|
-
* or null when the key is known. One home for the phrasing so whichever verb misspells a
|
|
191
|
-
* key, the typo reads the same. */
|
|
192
|
-
export function unknownConfigKeyError(key) {
|
|
193
|
-
if (TOP_LEVEL_KEYS.includes(key))
|
|
194
|
-
return null;
|
|
195
|
-
return `unknown config key "${key}" (valid top-level keys: ${TOP_LEVEL_KEYS.join(", ")})${typoSuffix(key, TOP_LEVEL_KEYS)}`;
|
|
196
|
-
}
|
|
197
|
-
/** The error for a dotted role key naming a field ROLE_ENTRY_KEYS does not hold — the one
|
|
198
|
-
* home of the wording `config set` and `config get` both surface, so a typo'd `roles.qa.colour`
|
|
199
|
-
* reads the same whichever verb names it (both refuse it; a silent `null` from get would look
|
|
200
|
-
* like a real unset value). Null when the field is a known role-entry key. */
|
|
201
|
-
export function unknownRoleFieldError(id, field) {
|
|
202
|
-
if (ROLE_ENTRY_KEYS.includes(field))
|
|
203
|
-
return null;
|
|
204
|
-
return `unknown role field "${field}" for roles.${id} (valid fields: ${ROLE_ENTRY_KEYS.join(", ")})${typoSuffix(field, ROLE_ENTRY_KEYS)}`;
|
|
205
|
-
}
|
|
206
105
|
/** Set ONE top-level key of tumwater.json from its raw CLI text (`tumwater config set`'s
|
|
207
106
|
* engine): the key must be a member of TOP_LEVEL_KEYS — the same protection checkKnownKeys
|
|
208
107
|
* gives the file itself, so `config set modle x` cannot write a dead key the runtime would
|
|
@@ -219,9 +118,10 @@ export function unknownRoleFieldError(id, field) {
|
|
|
219
118
|
* actionable message. Returns the parsed value and the previous one so the caller can
|
|
220
119
|
* confirm the change; on any failure the file is untouched (see writeConfigMutation). */
|
|
221
120
|
export function setConfigKey(root, key, rawValue) {
|
|
121
|
+
key = canonicalConfigKey(key);
|
|
222
122
|
const parsed = parseConfigKey(key);
|
|
223
123
|
if (parsed.kind === "error")
|
|
224
|
-
return
|
|
124
|
+
return fail(parsed.error);
|
|
225
125
|
let value;
|
|
226
126
|
try {
|
|
227
127
|
value = JSON.parse(rawValue);
|
|
@@ -232,21 +132,22 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
232
132
|
if (parsed.kind !== "top") {
|
|
233
133
|
const fieldError = parsed.kind === "role" ? unknownRoleFieldError(parsed.id, parsed.field) : null;
|
|
234
134
|
if (fieldError)
|
|
235
|
-
return
|
|
135
|
+
return fail(fieldError);
|
|
236
136
|
if (parsed.kind === "map") {
|
|
237
137
|
// Name the key the operator actually typed (`maxDailyCostUsdPerRole.qa`), not the
|
|
238
|
-
//
|
|
138
|
+
// team-wide field the shared validator's message would otherwise default to.
|
|
239
139
|
const label = `${parsed.map}.${parsed.role}`;
|
|
240
|
-
const validator = parsed.map === "maxDailyCostUsdPerRole"
|
|
140
|
+
const validator = parsed.map === "maxDailyCostUsdPerRole"
|
|
141
|
+
? checkDailyBudgetUsd
|
|
142
|
+
: parsed.map === "maxConcurrentPerProvider"
|
|
143
|
+
? checkProviderConcurrencyCap
|
|
144
|
+
: checkQuietHours;
|
|
241
145
|
const problem = validator(value, label);
|
|
242
146
|
if (problem)
|
|
243
|
-
return
|
|
147
|
+
return fail(problem);
|
|
244
148
|
}
|
|
245
149
|
if (parsed.kind === "tier" && (typeof value !== "string" || value === "")) {
|
|
246
|
-
return {
|
|
247
|
-
ok: false,
|
|
248
|
-
error: `model.${parsed.tier} must be a model selector string (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`) — got ${show(value)}`,
|
|
249
|
-
};
|
|
150
|
+
return fail(`model.${parsed.tier} must be a model id (e.g. \`config set model.strong huggingface/zai-org/GLM-5.3-Flash:together:low\`) — got ${show(value)}`);
|
|
250
151
|
}
|
|
251
152
|
let oldValue;
|
|
252
153
|
const result = writeConfigMutation(root, (cfg) => {
|
|
@@ -272,12 +173,12 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
272
173
|
// Merge one field of one backend's entry, preserving the other field and the other
|
|
273
174
|
// backends. validateConfig in writeConfigMutation owns the value type, so a bad
|
|
274
175
|
// value fails there and nothing is written.
|
|
275
|
-
const agents = { ...record.
|
|
176
|
+
const agents = { ...record.backends };
|
|
276
177
|
const entry = { ...(agents[parsed.id] ?? {}) };
|
|
277
178
|
oldValue = entry[parsed.field];
|
|
278
179
|
entry[parsed.field] = value;
|
|
279
180
|
agents[parsed.id] = entry;
|
|
280
|
-
const next = { ...cfg, agents };
|
|
181
|
+
const next = { ...cfg, backends: agents };
|
|
281
182
|
// Writers keep one spelling: the new pi form drops its legacy alias so the file can
|
|
282
183
|
// never hold both (validateConfig's raw pass rejects a file that does).
|
|
283
184
|
if (parsed.id === "pi") {
|
|
@@ -313,7 +214,7 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
313
214
|
}
|
|
314
215
|
const unknown = unknownConfigKeyError(key);
|
|
315
216
|
if (unknown)
|
|
316
|
-
return
|
|
217
|
+
return fail(unknown);
|
|
317
218
|
// Legacy `provider` / `fallbackModel` are accepted to FIX an existing value (a config
|
|
318
219
|
// that already has one keeps the old meaning), but `config set` never adds them to a
|
|
319
220
|
// config that lacks them — the new-form writers emit `model` and `fallback` only.
|
|
@@ -328,16 +229,13 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
328
229
|
}
|
|
329
230
|
})();
|
|
330
231
|
if (absent)
|
|
331
|
-
return {
|
|
332
|
-
ok: false,
|
|
333
|
-
error: `config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`,
|
|
334
|
-
};
|
|
232
|
+
return fail(`config set ${key}: this config has no ${key}, and a writer never adds legacy ${key} — set \`${key === "provider" ? "model" : "fallback"}\` instead`);
|
|
335
233
|
}
|
|
336
234
|
const perKeyValidator = PER_KEY_VALIDATORS[key];
|
|
337
235
|
if (perKeyValidator) {
|
|
338
236
|
const problem = perKeyValidator(value);
|
|
339
237
|
if (problem)
|
|
340
|
-
return
|
|
238
|
+
return fail(problem);
|
|
341
239
|
}
|
|
342
240
|
let oldValue;
|
|
343
241
|
const result = writeConfigMutation(root, (cfg) => {
|
|
@@ -349,29 +247,26 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
349
247
|
// When the key IS `roles` the value is the replacement, so it is left alone.
|
|
350
248
|
if (key !== "roles")
|
|
351
249
|
preserveFileRoles(root, next);
|
|
352
|
-
// Writers keep one spelling: a whole-section `
|
|
250
|
+
// Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
|
|
353
251
|
// legacy aliases the new pi form now carries, so the file can never hold both.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return next;
|
|
362
|
-
}
|
|
363
|
-
// Writers keep one spelling: a legacy top-level set drops the matching agents.pi field, so
|
|
252
|
+
// Writers keep one spelling: a whole-section `backends` set (the GUI Save path) drops the
|
|
253
|
+
// legacy agentBin/piArgs aliases the new pi form now carries, so the file can never hold
|
|
254
|
+
// both — the same collapseAgentAliases rule the whole-config writers (above and in
|
|
255
|
+
// config.ts) use, so the alias policy cannot drift between the per-key arm and the others.
|
|
256
|
+
if (key === "backends")
|
|
257
|
+
return collapseAgentAliases(next);
|
|
258
|
+
// Writers keep one spelling: a legacy top-level set drops the matching backends.pi field, so
|
|
364
259
|
// the file can never hold both (validateConfig's raw pass rejects a file that does).
|
|
365
260
|
if (key === "agentBin" || key === "piArgs") {
|
|
366
261
|
const record = next;
|
|
367
|
-
const agents = { ...(record.
|
|
262
|
+
const agents = { ...(record.backends ?? {}) };
|
|
368
263
|
const entry = { ...(agents.pi ?? {}) };
|
|
369
264
|
delete entry[key === "agentBin" ? "bin" : "args"];
|
|
370
265
|
if (Object.keys(entry).length === 0)
|
|
371
266
|
delete agents.pi;
|
|
372
267
|
else
|
|
373
268
|
agents.pi = entry;
|
|
374
|
-
return { ...next, agents };
|
|
269
|
+
return { ...next, backends: agents };
|
|
375
270
|
}
|
|
376
271
|
return next;
|
|
377
272
|
});
|
|
@@ -379,6 +274,66 @@ export function setConfigKey(root, key, rawValue) {
|
|
|
379
274
|
return result;
|
|
380
275
|
return { ok: true, value, oldValue };
|
|
381
276
|
}
|
|
277
|
+
/** Set a role's provider/model/thinking override triple in tumwater.json (`roles.<base>`),
|
|
278
|
+
* the write behind the dashboard role drawer's Model overrides editor: one writeConfigMutation
|
|
279
|
+
* rebuilds `roles.<base>` from the FILE's own `roles` section (the `rawConfigObject(root).roles` /
|
|
280
|
+
* `objectOrEmpty` idiom setConfigKey's role branch uses, so the write never re-expands the
|
|
281
|
+
* operator's partial section), and validateConfig's `roles.<id>` check (checkModelTriple,
|
|
282
|
+
* modelShape "tier-name") owns the whole candidate, so a bad `thinking` level writes nothing.
|
|
283
|
+
* An unknown base id writes nothing too — refused up front, because validateConfig is skipped
|
|
284
|
+
* on the no-op path (a clear of fields a role the file never held changes nothing) and the
|
|
285
|
+
* writer must not answer a phantom role "updated". `undefined` leaves that field as the file
|
|
286
|
+
* has it; `""` deletes it (never stores an empty string, which checkStringField rejects), and
|
|
287
|
+
* clearing the entry's last override removes `roles.<base>` entirely rather than leaving an
|
|
288
|
+
* empty object (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps).
|
|
289
|
+
* An all-no-op call (every named field already absent-or-equal) returns the loaded config
|
|
290
|
+
* unchanged and writeConfigMutation skips the write, so clearing keys the file never held
|
|
291
|
+
* leaves its bytes identical. */
|
|
292
|
+
export function setRoleModelFields(root, base, fields) {
|
|
293
|
+
// An unknown base id writes nothing. validateConfig owns the refusal when the write changes
|
|
294
|
+
// the candidate, but it is skipped on the no-op path (clearing fields a role the file never
|
|
295
|
+
// held changes nothing), so refuse the phantom role up front — the same knownRoleIds guard
|
|
296
|
+
// the CLI's unsetConfigKey clear paths and the GUI's rejectBadRole use — rather than answer
|
|
297
|
+
// a bogus id "updated".
|
|
298
|
+
if (!knownRoleIdsCached(root).includes(base))
|
|
299
|
+
return fail(unknownRoleMessage(base, knownRoleIdsCached(root)));
|
|
300
|
+
return writeConfigMutation(root, (cfg) => {
|
|
301
|
+
const rawRolesCandidate = rawConfigObject(root).roles;
|
|
302
|
+
const rawRoles = objectOrEmpty(rawRolesCandidate);
|
|
303
|
+
const rawEntryCandidate = rawRoles[base];
|
|
304
|
+
const rawEntry = objectOrEmpty(rawEntryCandidate);
|
|
305
|
+
const nextEntry = { ...rawEntry };
|
|
306
|
+
let changed = false;
|
|
307
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
308
|
+
if (value === undefined)
|
|
309
|
+
continue; // leave that field as the file has it
|
|
310
|
+
if (value === "") {
|
|
311
|
+
if (Object.prototype.hasOwnProperty.call(nextEntry, key)) {
|
|
312
|
+
delete nextEntry[key];
|
|
313
|
+
changed = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (nextEntry[key] !== value) {
|
|
317
|
+
nextEntry[key] = value;
|
|
318
|
+
changed = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!changed)
|
|
322
|
+
return cfg; // writeConfigMutation skips the write — file bytes identical
|
|
323
|
+
const nextRoles = { ...rawRoles };
|
|
324
|
+
// Clearing the entry's last override empties it — never leave an empty `roles.<base>: {}`
|
|
325
|
+
// (the file-wide no-empty-object invariant unsetConfigKey's `without` keeps); an emptied
|
|
326
|
+
// entry falls back to the catalog's defaults at load. When the section empties entirely,
|
|
327
|
+
// drop the `roles` key too, exactly as unsetConfigKey's role branch does.
|
|
328
|
+
if (Object.keys(nextEntry).length === 0)
|
|
329
|
+
delete nextRoles[base];
|
|
330
|
+
else
|
|
331
|
+
nextRoles[base] = nextEntry;
|
|
332
|
+
if (Object.keys(nextRoles).length === 0)
|
|
333
|
+
return without(cfg, "roles");
|
|
334
|
+
return { ...cfg, roles: nextRoles };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
382
337
|
/** A shallow copy of `obj` without `key` — the copy each `unsetConfigKey` branch edits
|
|
383
338
|
* before it decides whether the removal emptied its container, and the shape the branch then
|
|
384
339
|
* returns when it did (an emptied map, the last field of a `roles.<id>` entry, an emptied
|
|
@@ -390,7 +345,7 @@ function without(obj, key) {
|
|
|
390
345
|
}
|
|
391
346
|
/** The raw parsed tumwater.json as a plain object — the pre-defaults view `unsetConfigKey`'s
|
|
392
347
|
* presence check needs, because the merged config always carries defaults a top-level key or a
|
|
393
|
-
* `roles.<id>.<field>` (e.g. the seeded `
|
|
348
|
+
* `roles.<id>.<field>` (e.g. the seeded `minRunIntervalSeconds`) may never have been set to.
|
|
394
349
|
* Read inside writeConfigMutation's lock; a missing or unparsable file is `{}` (loadConfig has
|
|
395
350
|
* already rejected a broken file before a mutation runs, and an absent file is the bare
|
|
396
351
|
* defaults — there is nothing to retract). */
|
|
@@ -418,7 +373,7 @@ function preservingFileRoles(root, candidate) {
|
|
|
418
373
|
return candidate;
|
|
419
374
|
}
|
|
420
375
|
/** The config with top-level `key` set to `section`, or removed when `section` is empty — the
|
|
421
|
-
* unset shape for the per-role maps, `model` tiers and `
|
|
376
|
+
* unset shape for the per-role maps, `model` tiers and `backends`, which never leave an empty
|
|
422
377
|
* object in the file. A whole-config write, so the file's own roles section is kept. */
|
|
423
378
|
function withSectionOrDropped(root, cfg, key, section) {
|
|
424
379
|
const next = Object.keys(section).length === 0 ? without(cfg, key) : { ...cfg, [key]: section };
|
|
@@ -431,9 +386,9 @@ function withSectionOrDropped(root, cfg, key, section) {
|
|
|
431
386
|
* preserving its siblings, so an operator retracts one role's override without re-typing the
|
|
432
387
|
* rest. The containing map/`model` key is dropped when it empties, so the file never keeps an
|
|
433
388
|
* empty object (a `roles.<id>` entry cannot empty through the CLI: loadConfig always seeds
|
|
434
|
-
* `enabled`/`
|
|
389
|
+
* `enabled`/`minRunIntervalSeconds`). Presence is judged against the raw FILE, not the merged
|
|
435
390
|
* config: a key the file never set but the defaults fill in (a top-level `quietHours`/
|
|
436
|
-
* `maxConcurrent`, a seeded `roles.<id>.
|
|
391
|
+
* `maxConcurrent`, a seeded `roles.<id>.minRunIntervalSeconds`) is nothing to remove, so the
|
|
437
392
|
* mutation returns the loaded config unchanged and writeConfigMutation skips the write — the
|
|
438
393
|
* file's bytes stay identical rather than being re-serialized with the defaults expanded.
|
|
439
394
|
* Legacy `provider`/`fallbackModel` unset normally — only
|
|
@@ -442,18 +397,19 @@ function withSectionOrDropped(root, cfg, key, section) {
|
|
|
442
397
|
* still own truth, so a rejected candidate leaves the file byte-identical. oldValue is the
|
|
443
398
|
* file's own value removed, or `undefined` when the file never held it. */
|
|
444
399
|
export function unsetConfigKey(root, key) {
|
|
400
|
+
key = canonicalConfigKey(key);
|
|
445
401
|
const parsed = parseConfigKey(key);
|
|
446
402
|
if (parsed.kind === "error")
|
|
447
|
-
return
|
|
403
|
+
return fail(parsed.error);
|
|
448
404
|
if (parsed.kind === "role") {
|
|
449
405
|
const fieldError = unknownRoleFieldError(parsed.id, parsed.field);
|
|
450
406
|
if (fieldError)
|
|
451
|
-
return
|
|
407
|
+
return fail(fieldError);
|
|
452
408
|
}
|
|
453
409
|
if (parsed.kind === "top") {
|
|
454
410
|
const unknown = unknownConfigKeyError(key);
|
|
455
411
|
if (unknown)
|
|
456
|
-
return
|
|
412
|
+
return fail(unknown);
|
|
457
413
|
}
|
|
458
414
|
let oldValue;
|
|
459
415
|
const result = writeConfigMutation(root, (cfg) => {
|
|
@@ -493,22 +449,22 @@ export function unsetConfigKey(root, key) {
|
|
|
493
449
|
}
|
|
494
450
|
if (parsed.kind === "agent") {
|
|
495
451
|
// Presence is judged against the FILE, so unsetting a field only the default (or the
|
|
496
|
-
// legacy alias's resolved view) carries is a no-op success. The entry and the `
|
|
452
|
+
// legacy alias's resolved view) carries is a no-op success. The entry and the `backends`
|
|
497
453
|
// section are dropped when they empty, so the file never keeps an empty object.
|
|
498
|
-
const rawAgents = objectOrEmpty(raw.
|
|
454
|
+
const rawAgents = objectOrEmpty(raw.backends);
|
|
499
455
|
const rawEntryCandidate = rawAgents[parsed.id];
|
|
500
456
|
const rawEntry = objectOrEmpty(rawEntryCandidate);
|
|
501
457
|
if (!Object.hasOwn(rawEntry, parsed.field))
|
|
502
458
|
return cfg; // absent file field: a no-op success
|
|
503
459
|
oldValue = rawEntry[parsed.field];
|
|
504
|
-
const agents = { ...record.
|
|
460
|
+
const agents = { ...record.backends };
|
|
505
461
|
const entry = { ...(agents[parsed.id] ?? {}) };
|
|
506
462
|
delete entry[parsed.field];
|
|
507
463
|
if (Object.keys(entry).length === 0)
|
|
508
464
|
delete agents[parsed.id];
|
|
509
465
|
else
|
|
510
466
|
agents[parsed.id] = entry;
|
|
511
|
-
return withSectionOrDropped(root, cfg, "
|
|
467
|
+
return withSectionOrDropped(root, cfg, "backends", agents);
|
|
512
468
|
}
|
|
513
469
|
const rawRolesCandidate = raw.roles;
|
|
514
470
|
const rawRoles = objectOrEmpty(rawRolesCandidate);
|
|
@@ -535,13 +491,13 @@ export function unsetConfigKey(root, key) {
|
|
|
535
491
|
}
|
|
536
492
|
/** Consume the director's config-write request file (plans/portability.md §3/7). After its pi
|
|
537
493
|
* run the director may leave `.tumwater-config-request.json` at its worktree root:
|
|
538
|
-
* `{ "
|
|
494
|
+
* `{ "customRoles": [ { "name", "task" }, … ] }` — the whole array, replacing the current one.
|
|
539
495
|
* Applied atomically to the live config with setDailyBudgetUsd's idiom (fresh loadConfig that
|
|
540
496
|
* bypasses the stat cache, validateConfig, then writeJsonAtomic — readers poll every ~2 s), so
|
|
541
497
|
* the orchestrator's live reload starts the new loop with no commit, no review gate, and no
|
|
542
498
|
* merge, and the request file never enters a diff.
|
|
543
499
|
*
|
|
544
|
-
* The permitted key set is enforced here, not in prose: `
|
|
500
|
+
* The permitted key set is enforced here, not in prose: `customRoles` is accepted and every
|
|
545
501
|
* other top-level key is collected into `ignored` and dropped (the caller logs the warning that
|
|
546
502
|
* names them). Validation runs BEFORE any write and nothing here dereferences a request entry
|
|
547
503
|
* first — a structurally malformed entry (`[null]`, a non-string name) is left for
|
|
@@ -574,16 +530,21 @@ export function applyConfigRequest(root, wt) {
|
|
|
574
530
|
let applied = [];
|
|
575
531
|
let error;
|
|
576
532
|
try {
|
|
577
|
-
const
|
|
578
|
-
if (!isJsonObject(
|
|
579
|
-
throw new Error(`config request must be a JSON object (got ${show(
|
|
533
|
+
const unnormalized = JSON.parse(raw);
|
|
534
|
+
if (!isJsonObject(unnormalized))
|
|
535
|
+
throw new Error(`config request must be a JSON object (got ${show(unnormalized)})`);
|
|
536
|
+
// Old key spellings read as the new ones; a request setting both fails with the pair named.
|
|
537
|
+
const normalized = normalizeConfigKeys(unnormalized);
|
|
538
|
+
if (normalized.problems.length > 0)
|
|
539
|
+
throw new Error(normalized.problems.join("; "));
|
|
540
|
+
const parsed = normalized.raw;
|
|
580
541
|
for (const key of Object.keys(parsed)) {
|
|
581
|
-
if (key !== "
|
|
542
|
+
if (key !== "customRoles")
|
|
582
543
|
ignored.push(key);
|
|
583
544
|
}
|
|
584
|
-
const loops = parsed.
|
|
545
|
+
const loops = parsed.customRoles;
|
|
585
546
|
if (!Array.isArray(loops))
|
|
586
|
-
throw new Error(`config request's
|
|
547
|
+
throw new Error(`config request's customRoles must be an array (got ${show(loops)})`);
|
|
587
548
|
// The load-to-write sequence is one critical section under withConfigLock: a concurrent
|
|
588
549
|
// `config set`/dashboard write between the load and this write must not be dropped by the
|
|
589
550
|
// whole-file overwrite (see writeConfigMutation).
|
|
@@ -603,13 +564,13 @@ export function applyConfigRequest(root, wt) {
|
|
|
603
564
|
// only from what the file actually holds.
|
|
604
565
|
const rawRolesCandidate = rawConfigObject(root).roles;
|
|
605
566
|
const roles = isJsonObject(rawRolesCandidate)
|
|
606
|
-
? { ...rawRolesCandidate }
|
|
567
|
+
? { ...(rawRolesCandidate) }
|
|
607
568
|
: {};
|
|
608
|
-
for (const c of current.
|
|
569
|
+
for (const c of current.customRoles) {
|
|
609
570
|
if (!requestedNames.has(c.name))
|
|
610
571
|
delete roles[c.name];
|
|
611
572
|
}
|
|
612
|
-
const candidate = { ...current,
|
|
573
|
+
const candidate = { ...current, customRoles: loops };
|
|
613
574
|
const candidateRecord = candidate;
|
|
614
575
|
if (rawRolesCandidate === undefined)
|
|
615
576
|
delete candidateRecord.roles;
|
|
@@ -617,11 +578,11 @@ export function applyConfigRequest(root, wt) {
|
|
|
617
578
|
candidateRecord.roles = roles;
|
|
618
579
|
// Collapse legacy/new pi spellings before writing so a merged config (which always
|
|
619
580
|
// carries the default piArgs) never lands both forms in the file — the next load's raw
|
|
620
|
-
// pass rejects a file holding both `piArgs` and `
|
|
581
|
+
// pass rejects a file holding both `piArgs` and `backends.pi.args`.
|
|
621
582
|
const collapsed = collapseAgentAliases(candidate);
|
|
622
583
|
validateConfig(collapsed); // throws listing every problem — nothing is written on failure
|
|
623
584
|
writeJsonAtomic(configPath(root), collapsed, true);
|
|
624
|
-
applied = collapsed.
|
|
585
|
+
applied = collapsed.customRoles.map((c) => c.name);
|
|
625
586
|
});
|
|
626
587
|
}
|
|
627
588
|
catch (err) {
|