tumwater 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-run.js +3 -1
- package/dist/src/agent/claude/claude-account.js +6 -20
- package/dist/src/agent/codex/codex-account.js +5 -6
- package/dist/src/agent/codex/codex-args.js +5 -5
- package/dist/src/agent/codex/codex-rollout.js +1 -1
- package/dist/src/agent/opencode/opencode-backend.js +24 -22
- package/dist/src/agent/opencode/opencode-catalog.js +35 -9
- package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
- package/dist/src/agent/session-ledger.js +22 -26
- package/dist/src/agent/subscription-account.js +25 -41
- package/dist/src/agent/write-guard.js +50 -13
- package/dist/src/backlog/backlog-archive.js +3 -4
- package/dist/src/backlog/backlog-eligibility.js +21 -39
- package/dist/src/backlog/backlog-index.js +2 -2
- package/dist/src/backlog/backlog-md.js +13 -2
- package/dist/src/backlog/backlog-question-answer.js +1 -1
- package/dist/src/backlog/backlog-release.js +3 -11
- package/dist/src/backlog/backlog-write.js +19 -11
- package/dist/src/backlog/backlog.js +2 -1
- package/dist/src/baseline/main-baseline.js +47 -72
- package/dist/src/baseline/main-guardrails.js +13 -9
- package/dist/src/baseline/main-red.js +100 -12
- package/dist/src/baseline/main-watch.js +134 -54
- package/dist/src/brief/brief.js +26 -13
- package/dist/src/budget/budget-fallback-view.js +96 -28
- package/dist/src/budget/budget.js +4 -5
- package/dist/src/build/build-check-detect.js +22 -3
- package/dist/src/build/build-check-events.js +25 -5
- package/dist/src/build/build-check-report.js +47 -1
- package/dist/src/build/build-check-rerun.js +62 -0
- package/dist/src/build/build-check-scoped.js +13 -4
- package/dist/src/build/build-check.js +2 -1
- package/dist/src/build/build-info.js +4 -0
- package/dist/src/build/build-stage.js +6 -4
- package/dist/src/build/dep-install.js +1 -2
- package/dist/src/build/host-sleep.js +9 -0
- package/dist/src/change/change-data.js +1 -4
- package/dist/src/change/change-render.js +2 -1
- package/dist/src/cli/cli-args.js +34 -2
- package/dist/src/cli/cli-command-args.js +12 -4
- package/dist/src/cli/cli-completion-command.js +75 -0
- package/dist/src/cli/cli-doctor-command.js +103 -0
- package/dist/src/cli/cli-init.js +7 -3
- package/dist/src/cli/cli-query-commands.js +11 -7
- package/dist/src/cli/cli-review-commands.js +3 -3
- package/dist/src/cli/cli-role-command.js +339 -284
- package/dist/src/cli/cli-start-summary.js +4 -3
- package/dist/src/cli/cli-start-supervisor.js +190 -0
- package/dist/src/cli/cli-start.js +14 -189
- package/dist/src/cli/completion.js +9 -5
- package/dist/src/cli/follow-view.js +20 -2
- package/dist/src/cli/help.js +3 -8
- package/dist/src/cli.js +123 -303
- package/dist/src/collections.js +31 -13
- package/dist/src/concurrency/check-permit.js +30 -3
- package/dist/src/concurrency/lock.js +74 -15
- package/dist/src/config/config-editable-keys.js +9 -0
- package/dist/src/config/config-fallbacks.js +16 -5
- package/dist/src/config/config-keys.js +61 -20
- package/dist/src/config/config-request.js +11 -1
- package/dist/src/config/config-schema.js +4 -4
- package/dist/src/config/config-validation-roles.js +6 -22
- package/dist/src/config/config-validation.js +2 -1
- package/dist/src/config/config-views.js +62 -39
- package/dist/src/config/config-write.js +122 -79
- package/dist/src/config/config.js +13 -14
- package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
- package/dist/src/doctor/doctor-backlog-plans.js +29 -96
- package/dist/src/doctor/doctor-backlog.js +14 -16
- package/dist/src/doctor/doctor-check-names.js +2 -0
- package/dist/src/doctor/doctor-checks.js +16 -9
- package/dist/src/doctor/doctor-claude-checks.js +29 -11
- package/dist/src/doctor/doctor-contention-checks.js +20 -11
- package/dist/src/doctor/doctor-fallback-checks.js +83 -26
- package/dist/src/doctor/doctor-holds.js +9 -12
- package/dist/src/doctor/doctor-model-checks.js +7 -0
- package/dist/src/doctor/doctor-opencode-checks.js +2 -2
- package/dist/src/doctor/doctor-orphans.js +106 -29
- package/dist/src/doctor/doctor-project-checks.js +52 -22
- package/dist/src/doctor/doctor.js +5 -3
- package/dist/src/events/event-format.js +78 -109
- package/dist/src/events/event-labels.js +2 -1
- package/dist/src/events/event-legacy.js +2 -2
- package/dist/src/events/event-notify.js +4 -4
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +8 -0
- package/dist/src/failure/failure-cluster.js +4 -13
- package/dist/src/failure/failure-data.js +10 -8
- package/dist/src/failure/failure-render.js +2 -2
- package/dist/src/failure/failure-state-change.js +64 -90
- package/dist/src/files/file-queue.js +32 -19
- package/dist/src/files/files.js +2 -2
- package/dist/src/files/json-object.js +3 -2
- package/dist/src/gates/budget-gates.js +37 -2
- package/dist/src/gates/gate-polls.js +13 -13
- package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
- package/dist/src/gates/startup-gates.js +20 -2
- package/dist/src/git/git-diff.js +6 -2
- package/dist/src/git/git.js +8 -0
- package/dist/src/git/worktree-pool.js +18 -5
- package/dist/src/gui/gui-admin-endpoints.js +11 -1
- package/dist/src/gui/gui-args.js +14 -2
- package/dist/src/gui/gui-backlog-endpoints.js +2 -1
- package/dist/src/gui/gui-config-commands.js +2 -64
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-endpoint-common.js +19 -1
- package/dist/src/gui/gui-history-endpoints.js +3 -3
- package/dist/src/gui/gui-operator-commands.js +23 -28
- package/dist/src/gui/gui-prompt-commands.js +2 -2
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/gui/gui-team-commands.js +2 -1
- package/dist/src/history/history.js +14 -10
- package/dist/src/inbox/director-request.js +92 -0
- package/dist/src/merge/merge-backlog-conflicts.js +33 -11
- package/dist/src/merge/merge-batch.js +13 -235
- package/dist/src/merge/merge-cancel.js +2 -1
- package/dist/src/merge/merge-core.js +18 -52
- package/dist/src/merge/merge-drain.js +106 -101
- package/dist/src/merge/merge-pipeline.js +6 -4
- package/dist/src/merge/merge-post-review-drain.js +9 -3
- package/dist/src/merge/merge-questions.js +3 -3
- package/dist/src/merge/merge-queue-render.js +4 -4
- package/dist/src/merge/merge-queue.js +2 -1
- package/dist/src/merge/merge-requeue.js +62 -19
- package/dist/src/merge/merge-slot.js +41 -10
- package/dist/src/merge/merge-to-main.js +16 -210
- package/dist/src/merge/merge-top.js +2 -1
- package/dist/src/merge/merge-verify.js +9 -6
- package/dist/src/model-check/model-check-gate.js +206 -0
- package/dist/src/model-check/model-check-probe.js +109 -0
- package/dist/src/model-check/model-check.js +101 -0
- package/dist/src/operator/operator-commands.js +10 -26
- package/dist/src/orchestrator/orchestrator-launch.js +6 -1
- package/dist/src/orchestrator/orchestrator-poll.js +50 -43
- package/dist/src/orchestrator/orchestrator-scheduling.js +12 -16
- package/dist/src/orchestrator/orchestrator.js +14 -1
- package/dist/src/paths.js +24 -2
- package/dist/src/pi/pi-event-line.js +27 -0
- package/dist/src/pi/pi-listed-models.js +13 -0
- package/dist/src/pi/pi-run-result.js +16 -0
- package/dist/src/pi/pi-stream.js +9 -2
- package/dist/src/pi/pi-watchdogs.js +42 -10
- package/dist/src/process/process-group.js +2 -21
- package/dist/src/process/process.js +36 -4
- package/dist/src/process/run-marker.js +2 -2
- package/dist/src/prompt/gate-notes.js +10 -4
- package/dist/src/prompt/gate-prompts.js +14 -58
- package/dist/src/prompt/principles.js +13 -3
- package/dist/src/prompt/prompt-followup.js +14 -5
- package/dist/src/redeploy/redeploy-probes.js +53 -18
- package/dist/src/redeploy/redeploy-target.js +100 -0
- package/dist/src/redeploy/redeployer.js +120 -46
- package/dist/src/report/report-main-red.js +2 -2
- package/dist/src/report/report-render.js +2 -2
- package/dist/src/review/diff-rules.js +35 -21
- package/dist/src/review/known-flakes.js +4 -2
- package/dist/src/review/post-merge-review-accept.js +2 -1
- package/dist/src/review/post-merge-review.js +53 -41
- package/dist/src/review/review-precheck.js +75 -31
- package/dist/src/review/review.js +13 -22
- package/dist/src/roles/legacy-role-migration.js +6 -3
- package/dist/src/roles/role-catalog.js +13 -3
- package/dist/src/roles/role-render.js +157 -108
- package/dist/src/roles/role-view.js +271 -81
- package/dist/src/roles/roles.js +1 -1
- package/dist/src/run/ci-parity.js +34 -10
- package/dist/src/run/run-apply.js +0 -15
- package/dist/src/run/run-detail-render.js +2 -2
- package/dist/src/run/run-finalize.js +4 -0
- package/dist/src/run/run-stage.js +6 -6
- package/dist/src/run/run-timing.js +2 -2
- package/dist/src/run/run-usage.js +9 -1
- package/dist/src/run/run-verdict.js +92 -55
- package/dist/src/run/stage-check.js +17 -24
- package/dist/src/runner/runner-leftover.js +9 -26
- package/dist/src/runner/runner-outcome.js +1 -2
- package/dist/src/runner/runner-pi.js +12 -12
- package/dist/src/runner/runner-recovery.js +9 -16
- package/dist/src/runner/runner-revision.js +3 -3
- package/dist/src/runner/runner-run.js +155 -118
- package/dist/src/runner/runner-state-load.js +94 -61
- package/dist/src/runner/runner-state.js +3 -16
- package/dist/src/runner/runner.js +39 -20
- package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
- package/dist/src/scheduling/backoff.js +14 -9
- package/dist/src/scheduling/claim-entries.js +2 -1
- package/dist/src/scheduling/claims.js +10 -12
- package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +15 -74
- package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +113 -83
- package/dist/src/status/status-budget.js +3 -8
- package/dist/src/status/status-data.js +46 -41
- package/dist/src/status/status-polls.js +82 -25
- package/dist/src/status/status-role-notes.js +25 -7
- package/dist/src/status/status-roles.js +57 -27
- package/dist/src/team/team-hold.js +7 -5
- package/dist/src/team/team-orchestrator-info.js +104 -21
- package/dist/src/team/team-polls.js +36 -86
- package/dist/src/team/team-state.js +25 -9
- package/dist/src/text/datetime.js +16 -7
- package/dist/src/text/format.js +20 -2
- package/dist/src/text/phrases.js +66 -10
- package/dist/src/text/suggest.js +15 -2
- package/dist/src/text/text.js +33 -3
- package/dist/src/ui/alert-feeds.js +0 -41
- package/dist/src/ui/badges.js +76 -52
- package/dist/src/ui/gui/gui-client-boot.js +0 -6
- package/dist/src/ui/gui/gui-client-drawer-config.js +11 -20
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-model.js +4 -0
- package/dist/src/ui/gui/gui-client-operator.js +14 -15
- package/dist/src/ui/gui/gui-client-pending.js +10 -3
- package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
- package/dist/src/ui/gui/gui-client-settings.js +5 -1
- package/dist/src/ui/gui/gui-client-team.js +15 -2
- package/dist/src/ui/progress-data.js +54 -4
- package/dist/src/ui/run-progress-model.js +12 -3
- package/dist/src/ui/status-model.js +21 -6
- package/dist/src/ui/status-payload.js +23 -24
- package/dist/src/ui/status-render.js +12 -13
- package/dist/src/ui/team-alert-backlog.js +15 -205
- package/dist/src/ui/team-alert-shared.js +8 -31
- package/dist/src/ui/team-alert-standings.js +96 -0
- package/dist/src/ui/team-alert-status.js +39 -12
- package/dist/src/ui/team-alerts.js +9 -11
- package/dist/src/ui/tui/tui-backlog.js +14 -2
- package/dist/src/ui/tui/tui-editor-interval.js +2 -2
- package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
- package/dist/src/ui/tui/tui-editors.js +5 -4
- package/dist/src/ui/tui/tui-input.js +10 -8
- package/dist/src/ui/tui/tui-pane-activity.js +2 -1
- package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
- package/dist/src/ui/tui/tui-pane-health.js +46 -44
- package/dist/src/ui/tui/tui-pane-history.js +162 -475
- package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
- package/dist/src/ui/tui/tui-pane-pending.js +59 -331
- package/dist/src/ui/tui/tui-pane-queued.js +2 -2
- package/dist/src/ui/tui/tui-pane-session.js +4 -3
- package/dist/src/ui/tui/tui-pane-trail.js +420 -0
- package/dist/src/ui/tui/tui-pane.js +3 -3
- package/dist/src/ui/tui/tui-panes.js +9 -1
- package/dist/src/ui/tui/tui.js +4 -5
- package/dist/src/verdict/fix-claim.js +46 -11
- package/dist/src/verdict/reply-contract.js +5 -8
- package/package.json +9 -7
- package/dist/src/merge/merge-check-failures.js +0 -209
- package/dist/src/merge/merge-stack.js +0 -236
- package/dist/src/review/review-followup.js +0 -87
- package/dist/src/review/review-run.js +0 -395
- /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
- /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
- /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
- /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
- /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ and is also outside `npm test`.
|
|
|
42
42
|
## Status
|
|
43
43
|
|
|
44
44
|
<!-- tumwater:status:start -->
|
|
45
|
-
**v0.4.
|
|
45
|
+
**v0.4.5**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
|
|
46
46
|
which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
|
|
47
47
|
issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
|
|
48
48
|
|
package/dist/build-info.json
CHANGED
|
@@ -107,6 +107,7 @@ export function runAgent(opts) {
|
|
|
107
107
|
const wd = startPiWatchdogs({
|
|
108
108
|
progressCount: () => parser.progressCount,
|
|
109
109
|
openToolCalls: () => parser.openToolCalls,
|
|
110
|
+
pendingReply: () => parser.pendingReply,
|
|
110
111
|
runTimeoutMs: opts.config.runTimeoutSeconds * 1000,
|
|
111
112
|
quietMs: opts.config.quietTimeoutSeconds * 1000,
|
|
112
113
|
stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
|
|
@@ -120,7 +121,8 @@ export function runAgent(opts) {
|
|
|
120
121
|
// deadline stays right for them.
|
|
121
122
|
extendOnProgress: opts.kind === "gate",
|
|
122
123
|
kill: () => terminateChild(child),
|
|
123
|
-
|
|
124
|
+
onStallWarning: opts.onStallWarning,
|
|
125
|
+
onUnansweredReply: opts.onUnansweredReply,
|
|
124
126
|
});
|
|
125
127
|
let aborted = false;
|
|
126
128
|
// A blind image read on an authoring run deliberately kills it, like the watchdogs do.
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* subscription-account.ts) — which billing pays for claude runs, and the newest subscription
|
|
3
3
|
* usage-window reading each run reported (plans/agent-backends.md §7 "Billing" and §8 "The
|
|
4
4
|
* subscription usage window"). Persisted at `.tumwater/state/claude-account.json`, written only
|
|
5
|
-
* by the orchestrator process, so the sibling parts
|
|
6
|
-
*
|
|
5
|
+
* by the orchestrator process, so the sibling parts read one answer instead of re-deriving it
|
|
6
|
+
* per surface.
|
|
7
7
|
*
|
|
8
8
|
* Everything here is a thin wrapper over the generic per-backend store (found
|
|
9
9
|
* grounds/codex-backend.md "Groundwork: the subscription account is per backend", item 1),
|
|
@@ -17,7 +17,7 @@ import { spawnSync } from "node:child_process";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import { resolvedBackendConfig } from "../../config/config-views.js";
|
|
19
19
|
import { parseJsonObject } from "../../files/json-object.js";
|
|
20
|
-
import { observeAccount, readAccount, subscriptionWindowRejected,
|
|
20
|
+
import { observeAccount, readAccount, subscriptionWindowRejected, writeAccount, } from "../subscription-account.js";
|
|
21
21
|
import { claudeBackend, claudeConfigDir } from "./claude-backend.js";
|
|
22
22
|
import { claudeEnv, claudeThirdPartyProvider } from "./claude-env.js";
|
|
23
23
|
/** Read the persisted claude account state, or null when it is absent or unreadable. A file
|
|
@@ -43,23 +43,9 @@ export function writeClaudeAccount(root, account) {
|
|
|
43
43
|
export function observeClaudeAccount(root, run, now = Date.now()) {
|
|
44
44
|
return observeAccount(root, "claude", run, now);
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
47
|
-
*
|
|
48
|
-
export
|
|
49
|
-
return windowUsedUp(account, now, includeOverage, reserve);
|
|
50
|
-
}
|
|
51
|
-
/** Re-export of the per-backend helpers under their historical claude names for callers that
|
|
52
|
-
* predate the per-backend store (budget gates, team holds, the doctor, the role view, the
|
|
53
|
-
* status poll). */
|
|
54
|
-
export { subscriptionWindowRejected, windowReserved };
|
|
55
|
-
/** The window type as a compact label — subscription-account.ts's windowLabel, under its
|
|
56
|
-
* historical claude name. */
|
|
57
|
-
export function claudeWindowLabel(type) {
|
|
58
|
-
return windowLabel(type);
|
|
59
|
-
}
|
|
60
|
-
export function claudeWindowEntries(account) {
|
|
61
|
-
return windowEntries(account);
|
|
62
|
-
}
|
|
46
|
+
/** Re-export of the per-backend rejection signal under its historical claude name for callers
|
|
47
|
+
* that predate the per-backend store (the runner's run-verdict and hold wiring). */
|
|
48
|
+
export { subscriptionWindowRejected };
|
|
63
49
|
/** Seed the claude account file before the first run (plans/agent-backends.md §7):
|
|
64
50
|
* `ANTHROPIC_API_KEY` set means claude prefers the key, so billing is API; otherwise
|
|
65
51
|
* `claude auth status` decides — a claude.ai OAuth login is a subscription, any other login is
|
|
@@ -84,12 +84,11 @@ export function parseCodexLoginStatus(stdout) {
|
|
|
84
84
|
return "api";
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
|
-
/** The refusal message
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* the resolved `CODEX_HOME` path. */
|
|
87
|
+
/** The refusal message for a codex logged in with an API key, shared by the startup gate, the
|
|
88
|
+
* live-config warn and the doctor's codex auth check (decision 5): Codex reports no cost, so
|
|
89
|
+
* per-token billing would spend money the budget cap cannot see. `home` names the codex home
|
|
90
|
+
* the operator should re-login under; the gate and the live warn pass the literal "<home>"
|
|
91
|
+
* placeholder because they do not resolve one, while the doctor passes the resolved home. */
|
|
93
92
|
export function codexApiKeyRefusalMessage(home) {
|
|
94
93
|
return `Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run \`CODEX_HOME=${home} codex login --device-auth\``;
|
|
95
94
|
}
|
|
@@ -106,11 +106,11 @@ export const EFFORT_BY_THINKING = {
|
|
|
106
106
|
* spawn on a model that lists it. */
|
|
107
107
|
const KNOWN_EFFORTS = new Set(Object.values(EFFORT_BY_THINKING));
|
|
108
108
|
/** Map a thinking level to the Codex `model_reasoning_effort` it means, gated by the model's own
|
|
109
|
-
* catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which
|
|
110
|
-
*
|
|
111
|
-
* result is returned unclamped. An unknown thinking level returns `undefined` (no effort
|
|
112
|
-
* the level simply has no Codex effort, unlike a thinking level that maps but the model
|
|
113
|
-
* support, which is also `undefined`. */
|
|
109
|
+
* catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which the
|
|
110
|
+
* codex catalog (part 8/9) supplies; when the caller has no catalog it passes `undefined`, and
|
|
111
|
+
* the result is returned unclamped. An unknown thinking level returns `undefined` (no effort
|
|
112
|
+
* flag) — the level simply has no Codex effort, unlike a thinking level that maps but the model
|
|
113
|
+
* does not support, which is also `undefined`. */
|
|
114
114
|
export function codexEffort(thinking, levels) {
|
|
115
115
|
if (!Object.hasOwn(EFFORT_BY_THINKING, thinking))
|
|
116
116
|
return undefined;
|
|
@@ -163,7 +163,7 @@ function rateLimitLines(lastRateLimits) {
|
|
|
163
163
|
/** The model this turn switched to when its `turn_context.model` differs from what the run
|
|
164
164
|
* requested (finding 24): the model itself, else undefined for no switch. */
|
|
165
165
|
function switchedToModel(model, requestedModel) {
|
|
166
|
-
return model !== undefined &&
|
|
166
|
+
return model !== undefined && model !== requestedModel ? model : undefined;
|
|
167
167
|
}
|
|
168
168
|
/** Assemble the reading's canonical `run_usage`, `billing` and up-to-two `rate_limit` lines
|
|
169
169
|
* (in that order), plus the `switchedTo` model comparison, from scanned records. */
|
|
@@ -79,42 +79,44 @@ export function opencodeSpillDir(root) {
|
|
|
79
79
|
function catalogModelKey(model) {
|
|
80
80
|
return opencodeModelKey(model);
|
|
81
81
|
}
|
|
82
|
-
/** Run `opencode session export <id> --standalone`
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
82
|
+
/** Run the bounded `opencode session export <id> --standalone` spawn that both the resume
|
|
83
|
+
* existence check and the reconciling export issue, under the same env/cwd and the shared
|
|
84
|
+
* 10 s bound. `capture` is false for the existence check, which reads only the exit status:
|
|
85
|
+
* `stdio: "ignore"` keeps the export — which carries every message of the session — out of
|
|
86
|
+
* memory and below the default 1 MiB `maxBuffer`, whose `ENOBUFS` kill would otherwise make a
|
|
87
|
+
* large but real session read as absent and mint a fresh one. Returns the spawn result, or null
|
|
88
|
+
* when the spawn itself threw — a missing binary or an invalid option, resolved the same way
|
|
89
|
+
* both callers treated a throw. The command line, env, cwd and timeout live here once, so the
|
|
90
|
+
* existence check and the reconciling export cannot drift on what they run. */
|
|
91
|
+
function runSessionExport(id, env, bin, cwd, capture = true) {
|
|
87
92
|
try {
|
|
88
|
-
|
|
93
|
+
return spawnSync(bin, ["session", "export", id, "--standalone"], {
|
|
89
94
|
env,
|
|
90
95
|
cwd,
|
|
91
96
|
timeout: EXPORT_TIMEOUT_MS,
|
|
92
|
-
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
stdio: capture ? "pipe" : "ignore",
|
|
93
99
|
});
|
|
94
|
-
return res.status === 0;
|
|
95
100
|
}
|
|
96
101
|
catch {
|
|
97
|
-
return
|
|
102
|
+
return null;
|
|
98
103
|
}
|
|
99
104
|
}
|
|
105
|
+
/** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
|
|
106
|
+
* 10 s) and return whether it reported success — the existence check that decides a resume.
|
|
107
|
+
* Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
|
|
108
|
+
* direction: `--session` would otherwise silently create a missing session. */
|
|
109
|
+
function opencodeSessionExists(id, env, bin, cwd) {
|
|
110
|
+
const res = runSessionExport(id, env, bin, cwd, false);
|
|
111
|
+
return res?.status === 0;
|
|
112
|
+
}
|
|
100
113
|
/** Run the reconciling export after a successful run and return its two canonical lines
|
|
101
114
|
* (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
|
|
102
115
|
* timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
|
|
103
116
|
* (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
|
|
104
117
|
function opencodeExport(result) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
|
|
108
|
-
env: result.env,
|
|
109
|
-
cwd: result.cwd,
|
|
110
|
-
timeout: EXPORT_TIMEOUT_MS,
|
|
111
|
-
encoding: "utf8",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
if (res.status !== 0 || !res.stdout)
|
|
118
|
+
const res = runSessionExport(result.id, result.env, result.bin, result.cwd);
|
|
119
|
+
if (res?.status !== 0 || !res.stdout)
|
|
118
120
|
return [];
|
|
119
121
|
let parsed;
|
|
120
122
|
try {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { isJsonObject, validEntries } from "../../files/json-object.js";
|
|
11
11
|
import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
|
|
12
|
+
import { cachedByStat } from "../../files/stat-cache.js";
|
|
12
13
|
import { errorMessage } from "../../text/text.js";
|
|
13
14
|
import { DAY_MS } from "../../text/datetime.js";
|
|
14
15
|
import { opencodeModelsPath } from "../../paths.js";
|
|
@@ -21,6 +22,25 @@ const CATALOG_URL = "https://models.dev/api.json";
|
|
|
21
22
|
const CATALOG_FETCH_TIMEOUT_MS = 30_000;
|
|
22
23
|
/** How long a fetched cache is trusted before it is refreshed. */
|
|
23
24
|
const CATALOG_CACHE_TTL_MS = DAY_MS;
|
|
25
|
+
/** Per-catalog cache of the parsed catalog, keyed by the models.json path: the status
|
|
26
|
+
* snapshot and the budget gate both poll it every second while the file changes only when a
|
|
27
|
+
* refresh re-fetches models.dev — and it grows with the models.dev catalog — so an
|
|
28
|
+
* unchanged file costs one stat per poll instead of a re-read plus a JSON.parse and a
|
|
29
|
+
* validation of the whole file (the same stat-keyed contract readOpencodeCache's siblings
|
|
30
|
+
* readCodexCatalog, readPiProviders and readMergeQueue already use). Any write invalidates
|
|
31
|
+
* via dev/ino/mtime/size (stat-cache.cachedByStat's freshness check). */
|
|
32
|
+
const catalogCache = new Map();
|
|
33
|
+
/** A structural copy of a parsed catalog, so a caller that mutates its own result (e.g.
|
|
34
|
+
* observeOpencodeRun sets `billedAt` on an entry) never reaches the shared cache entry; the
|
|
35
|
+
* next poll re-derives from the file. The entries are plain data (`efforts` arrays included)
|
|
36
|
+
* so a shallow model-object spread suffices. */
|
|
37
|
+
function cloneCache(cache) {
|
|
38
|
+
const models = {};
|
|
39
|
+
for (const [key, entry] of Object.entries(cache.models)) {
|
|
40
|
+
models[key] = entry.efforts !== undefined ? { ...entry, efforts: [...entry.efforts] } : { ...entry };
|
|
41
|
+
}
|
|
42
|
+
return { fetchedAt: cache.fetchedAt, cliVersion: cache.cliVersion, models };
|
|
43
|
+
}
|
|
24
44
|
const defaultFetch = async (url, timeoutMs) => {
|
|
25
45
|
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
26
46
|
if (!res.ok)
|
|
@@ -160,15 +180,21 @@ function isOpencodeCatalogModel(value) {
|
|
|
160
180
|
* fills the file in. Entries that are not well-formed catalog models are dropped, so a foreign
|
|
161
181
|
* entry reads as a model missing from the cache — not free. */
|
|
162
182
|
export function readOpencodeCache(root) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const file = opencodeModelsPath(root);
|
|
184
|
+
const cached = cachedByStat(catalogCache, file, file, () => {
|
|
185
|
+
// readJsonFile is the shared "missing or torn reads as no data" policy; a torn write
|
|
186
|
+
// or a malformed file reads as an empty cache (fetchedAt 0), exactly as before.
|
|
187
|
+
const existing = readJsonFile(file);
|
|
188
|
+
if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
|
|
189
|
+
return {
|
|
190
|
+
fetchedAt: existing.fetchedAt,
|
|
191
|
+
cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
|
|
192
|
+
models: validEntries(existing.models, isOpencodeCatalogModel),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { fetchedAt: 0, models: {} };
|
|
196
|
+
}, cloneCache);
|
|
197
|
+
return cached ?? { fetchedAt: 0, models: {} };
|
|
172
198
|
}
|
|
173
199
|
/** Persist the catalog cache atomically under `.tumwater/state/`. */
|
|
174
200
|
export function writeOpencodeCache(root, cache) {
|
|
@@ -4,20 +4,23 @@
|
|
|
4
4
|
*
|
|
5
5
|
* OpenCode 2.x has no hook that blocks a tool from running. It has a permission `evaluate` hook
|
|
6
6
|
* that may turn a decision to `deny` with a `message`, which reaches the model as the tool's error
|
|
7
|
-
* while the run goes on.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* while the run goes on. The file-writing tools ask for the `edit` action (edit in place) and the
|
|
8
|
+
* `write` action (create or overwrite a file — a new path a `../` climb can point outside the
|
|
9
|
+
* checkout) with the absolute paths they write as `resources`, and the hook runs under `--auto`
|
|
10
|
+
* too, since `--auto` only answers what would otherwise be asked. Checked live on opencode 2.1.0
|
|
11
|
+
* on 2026-10-23. A configured plugin path must be a directory, which is why this is `index.js` in
|
|
12
|
+
* a directory of its own.
|
|
12
13
|
*
|
|
13
14
|
* The plugin's context names the run's directory (`location.directory`, the worktree OpenCode
|
|
14
15
|
* runs in), which is the run directory the rule reads its checkout from. A failure of its own
|
|
15
16
|
* leaves the decision alone, so a broken guard cannot stop every edit of a run. */
|
|
16
17
|
import { strayWriteRefusal } from "../../write-guard.js";
|
|
17
|
-
/** Deny an `edit` decision whose resources include a stray path from a run in
|
|
18
|
-
* the first such path's refusal. Every other decision is left as it is. Exported
|
|
18
|
+
/** Deny an `edit` or `write` decision whose resources include a stray path from a run in
|
|
19
|
+
* `runDir`, naming the first such path's refusal. Every other decision is left as it is. Exported
|
|
20
|
+
* for tests. */
|
|
21
|
+
const FILE_WRITE_ACTIONS = new Set(["edit", "write"]);
|
|
19
22
|
export function guardEvaluate(event, runDir) {
|
|
20
|
-
if (event.action
|
|
23
|
+
if (!FILE_WRITE_ACTIONS.has(event.action) || !Array.isArray(event.resources))
|
|
21
24
|
return;
|
|
22
25
|
for (const resource of event.resources) {
|
|
23
26
|
if (typeof resource !== "string" || resource === "")
|
|
@@ -191,6 +191,24 @@ export function pruneSessionLedgers(sessionsRoot, days) {
|
|
|
191
191
|
/** How long one `opencode session delete <id> --standalone` may take before it gives up: the
|
|
192
192
|
* session export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. */
|
|
193
193
|
const SESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
194
|
+
/** Run one bounded headless CLI session delete (`bin args…`) under `env`, best-effort: a
|
|
195
|
+
* spawn that throws (a missing binary, an unwritable env) returns false without throwing, so
|
|
196
|
+
* a failed delete never fails the prune. Shared by the OpenCode and Codex session purges below
|
|
197
|
+
* so neither drift on the 10 s bound, the status-0 success rule, or the swallow policy. */
|
|
198
|
+
function sessionDeleteSucceeds(bin, args, env) {
|
|
199
|
+
try {
|
|
200
|
+
const res = spawnSync(bin, args, {
|
|
201
|
+
env,
|
|
202
|
+
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
203
|
+
stdio: "ignore",
|
|
204
|
+
});
|
|
205
|
+
return res.status === 0;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
194
212
|
/** Delete each OpenCode session the ledger records as expired, with one
|
|
195
213
|
* `opencode session delete <id> --standalone` each (bounded), plus the spill files older than
|
|
196
214
|
* the window in the team's shell spill dir. The session's spill files and server state live
|
|
@@ -209,18 +227,8 @@ export function pruneOpencodeSessions(sessionsRoot, days, opts) {
|
|
|
209
227
|
for (const record of readSessions(path.dirname(file))) {
|
|
210
228
|
if (record.agent !== "opencode" || record.at >= cutoff)
|
|
211
229
|
continue;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
env: opts.env,
|
|
215
|
-
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
216
|
-
stdio: "ignore",
|
|
217
|
-
});
|
|
218
|
-
if (res.status === 0)
|
|
219
|
-
deleted += 1;
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
223
|
-
}
|
|
230
|
+
if (sessionDeleteSucceeds(opts.bin, ["session", "delete", record.id, "--standalone"], opts.env))
|
|
231
|
+
deleted += 1;
|
|
224
232
|
}
|
|
225
233
|
});
|
|
226
234
|
}
|
|
@@ -276,20 +284,8 @@ export function pruneCodexSessions(sessionsRoot, days, opts) {
|
|
|
276
284
|
for (const id of expired) {
|
|
277
285
|
if (live.has(id))
|
|
278
286
|
continue;
|
|
279
|
-
if (opts.bin !== undefined)
|
|
280
|
-
|
|
281
|
-
const res = spawnSync(opts.bin, ["delete", "--force", id], {
|
|
282
|
-
env: opts.env,
|
|
283
|
-
timeout: SESSION_DELETE_TIMEOUT_MS,
|
|
284
|
-
stdio: "ignore",
|
|
285
|
-
});
|
|
286
|
-
if (res.status === 0)
|
|
287
|
-
deleted += 1;
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
// Best effort: an unwritable env or a missing binary must not fail the prune.
|
|
291
|
-
}
|
|
292
|
-
}
|
|
287
|
+
if (opts.bin !== undefined && sessionDeleteSucceeds(opts.bin, ["delete", "--force", id], opts.env))
|
|
288
|
+
deleted += 1;
|
|
293
289
|
// Fallback: unlink the home's rollout for this session so neither the ledger line nor
|
|
294
290
|
// its file outlives the window. A real `codex delete` removes the rollout itself, so a
|
|
295
291
|
// successful delete has nothing left to unlink; a failed or skipped delete does, and
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* "Billing" and §8 "The subscription usage window"). Persisted at
|
|
4
4
|
* `.tumwater/state/<backend>-account.json` (plan design grounds/codex-backend.md "Groundwork:
|
|
5
5
|
* the subscription account is per backend", item 1), written only by the orchestrator process,
|
|
6
|
-
* so the
|
|
7
|
-
*
|
|
6
|
+
* so the free-ness rule and the window displays read one answer instead of re-deriving it per
|
|
7
|
+
* surface.
|
|
8
8
|
*
|
|
9
9
|
* The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
|
|
10
10
|
* missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* keeps today's shape. */
|
|
16
16
|
import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
|
|
17
17
|
import { finiteNumber, isJsonObject, setOwn } from "../files/json-object.js";
|
|
18
|
+
import { cachedByStat } from "../files/stat-cache.js";
|
|
18
19
|
import { accountPath } from "../paths.js";
|
|
19
20
|
/** True when a failed run ended on a subscription usage window the account reported as
|
|
20
21
|
* `rejected` without overage — the signal the team hold throttles on (plans/agent-backends.md
|
|
@@ -28,45 +29,12 @@ export function subscriptionWindowRejected(run) {
|
|
|
28
29
|
run.billing === "subscription" &&
|
|
29
30
|
(run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
|
|
30
31
|
}
|
|
31
|
-
/** The subscription account's used-up usage window, or null: a window reading `rejected` with
|
|
32
|
-
* no overage, or (with `includeOverage`, the default) a window whose current reading shows
|
|
33
|
-
* `isUsingOverage`, and a `resetsAt` still ahead of `now`; among several, the one that resets
|
|
34
|
-
* last. With a `reserve` (the subscription-window reserve), a window whose utilization has
|
|
35
|
-
* reached its type's fraction counts too and comes back with `reserve: true`; a rejected or
|
|
36
|
-
* overage window outranks a reserve one, so the flag is set only when no window is used up
|
|
37
|
-
* for real. The budget gate's window move (pollBudgetGate) reads the default; the team hold
|
|
38
|
-
* (windowObservation) passes `includeOverage: false`, because overage never holds a
|
|
39
|
-
* seam. Only subscription billing produces one; a missing account reads null. */
|
|
40
|
-
export function windowUsedUp(account, now, includeOverage = true, reserve) {
|
|
41
|
-
if (account?.billing !== "subscription")
|
|
42
|
-
return null;
|
|
43
|
-
let furthest = null;
|
|
44
|
-
let reserved = null;
|
|
45
|
-
for (const [type, w] of Object.entries(account.windows)) {
|
|
46
|
-
if (typeof w.resetsAt !== "number" || w.resetsAt <= now)
|
|
47
|
-
continue;
|
|
48
|
-
const used = w.isUsingOverage === true ? includeOverage : w.status === "rejected";
|
|
49
|
-
if (used) {
|
|
50
|
-
if (furthest === null || w.resetsAt > furthest.resetsAt)
|
|
51
|
-
furthest = { type, resetsAt: w.resetsAt };
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (windowReserved(w, reserve?.[type]) && (reserved === null || w.resetsAt > reserved.resetsAt))
|
|
55
|
-
reserved = { type, resetsAt: w.resetsAt, reserve: true };
|
|
56
|
-
}
|
|
57
|
-
return furthest ?? reserved;
|
|
58
|
-
}
|
|
59
|
-
/** Has this window's utilization reached its reserve fraction? False without a fraction or a
|
|
60
|
-
* reading. */
|
|
61
|
-
export function windowReserved(w, fraction) {
|
|
62
|
-
return fraction !== undefined && typeof w.utilization === "number" && w.utilization >= fraction;
|
|
63
|
-
}
|
|
64
32
|
/** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
|
|
65
33
|
* every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
|
|
66
34
|
const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
|
|
67
35
|
/** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
|
|
68
36
|
* type falls back to its words with underscores as spaces. The one home of the window spelling
|
|
69
|
-
* the status badge and the role view share. */
|
|
37
|
+
* the status badge and the role view share (the browser dashboard keeps its inline copy). */
|
|
70
38
|
export function windowLabel(type) {
|
|
71
39
|
const m = /^(\w+)_(hour|day|week)$/.exec(type);
|
|
72
40
|
if (!m)
|
|
@@ -77,10 +45,8 @@ export function windowLabel(type) {
|
|
|
77
45
|
return `${words[word] ?? word}${unit}`;
|
|
78
46
|
}
|
|
79
47
|
/** The fraction of a subscription usage window consumed (0–1) as a whole-number percent: a
|
|
80
|
-
* `0.64` utilization reads `64`. The one home of the fraction→percent conversion
|
|
81
|
-
* the
|
|
82
|
-
* fill identically (the browser dashboard's own window line is embedded client-side and keeps
|
|
83
|
-
* its inline copy). */
|
|
48
|
+
* `0.64` utilization reads `64`. The one node-side home of the fraction→percent conversion
|
|
49
|
+
* (the browser dashboard's own window line is embedded client-side and keeps its inline copy). */
|
|
84
50
|
export function utilizationPct(utilization) {
|
|
85
51
|
return Math.round(utilization * 100);
|
|
86
52
|
}
|
|
@@ -107,12 +73,30 @@ export function windowEntries(account) {
|
|
|
107
73
|
.map(([type, w]) => ({ type, ...w }))
|
|
108
74
|
.sort((a, b) => compareWindowEntries(a, b));
|
|
109
75
|
}
|
|
76
|
+
/** The parsed raw body of one backend's account file, keyed by that file's own stat — the same
|
|
77
|
+
* stat-keyed contract cachedByStat gives every observer that polls a slowly-changing file. The
|
|
78
|
+
* account file is written only by the orchestrator on a run's billing/window report, so it
|
|
79
|
+
* changes no faster than runs; on the status snapshot path readAccount ran per backend per poll
|
|
80
|
+
* as one more readJsonFile (read + parse) alongside the already stat-cached marker reads, and
|
|
81
|
+
* budget-gates/role-view read it on other polling surfaces. Writes go through writeJsonAtomic's
|
|
82
|
+
* tmp+rename (writeAccount), which bumps dev/ino/mtime/size, so a refresh invalidates like any
|
|
83
|
+
* edit. The `now`-dependent heal is NOT cached: only the raw body is, and readAccount reapplies
|
|
84
|
+
* its window/observedAt rules against each call's own `now` on a per-call copy, so a window
|
|
85
|
+
* whose `resetsAt` lapses still reads back as reset on the very call it does. Bounded inside
|
|
86
|
+
* cachedByStat. */
|
|
87
|
+
const accountBodyCache = new Map();
|
|
88
|
+
/** Serve `file`'s parsed raw JSON-object body from accountBodyCache: null when the file is
|
|
89
|
+
* missing, unreadable, torn, or not a plain object — the same no-data policy as readJsonFile —
|
|
90
|
+
* and a fresh per-call copy so readAccount's owns the object it reads fields off. Never throws. */
|
|
91
|
+
function readAccountBody(file) {
|
|
92
|
+
return cachedByStat(accountBodyCache, file, file, () => readJsonFile(file), (body) => ({ ...body }));
|
|
93
|
+
}
|
|
110
94
|
/** Read the persisted account state for a backend, or null when it is absent or unreadable. A
|
|
111
95
|
* file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
|
|
112
96
|
* and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
|
|
113
97
|
* reader, not every consumer, decides that the window no longer limits. */
|
|
114
98
|
export function readAccount(root, backend, now = Date.now()) {
|
|
115
|
-
const doc =
|
|
99
|
+
const doc = readAccountBody(accountPath(root, backend));
|
|
116
100
|
if (doc === null)
|
|
117
101
|
return null;
|
|
118
102
|
const billing = doc.billing === "subscription" || doc.billing === "api" ? (doc.billing) : undefined;
|
|
@@ -27,25 +27,62 @@ function isWithin(p, dir) {
|
|
|
27
27
|
const rel = path.relative(dir, p);
|
|
28
28
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
29
29
|
}
|
|
30
|
-
/** `p` with its
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* unchanged.
|
|
30
|
+
/** `p` with its symlinks resolved as the file it reaches: each component is peeled and a
|
|
31
|
+
* symlink is followed to its target, so a symlinked checkout or a `/tmp` that is `/private/tmp`
|
|
32
|
+
* compares as the path it reaches. The not-yet-existing tail (a file a write would create) is
|
|
33
|
+
* appended unchanged. A broken symlink — target absent, the write/create case, where
|
|
34
|
+
* `realpathSync` throws ENOENT and the old longest-existing-prefix walk read the link as its
|
|
35
|
+
* own location inside the checkout — is followed to its target instead: the write would follow
|
|
36
|
+
* it, so the guard must see where it points. */
|
|
34
37
|
function realish(p) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Peel the path component by component, exactly as `realpath` does: a regular component is
|
|
39
|
+
// kept, a symlink is replaced by the components its target resolves to, and the first absent
|
|
40
|
+
// component ends the walk, so a not-yet-existing tail is appended unchanged. Replacing the
|
|
41
|
+
// link with its target — instead of keeping the link's own path — is what catches a broken
|
|
42
|
+
// symlink: its target is absent (the write/create case, where `realpathSync` throws ENOENT
|
|
43
|
+
// and the old longest-existing-prefix walk read the link as its location inside the
|
|
44
|
+
// checkout), yet the write would still follow it, so the guard must see where it points. A
|
|
45
|
+
// depth bound stops a symlink cycle (a → b → a) from looping instead of escaping through the
|
|
46
|
+
// tail.
|
|
47
|
+
let q = path.resolve(p).split(path.sep).filter(Boolean);
|
|
48
|
+
const out = [];
|
|
49
|
+
let depth = 0;
|
|
50
|
+
while (q.length > 0) {
|
|
51
|
+
const part = q[0]; // the loop's length check guarantees a first component
|
|
52
|
+
const here = path.join(path.sep, ...out, part);
|
|
53
|
+
let st;
|
|
38
54
|
try {
|
|
39
|
-
|
|
55
|
+
st = fs.lstatSync(here);
|
|
40
56
|
}
|
|
41
57
|
catch {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
out.push(part, ...q.slice(1)); // absent: this and the rest are the not-yet-existing tail
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (!st.isSymbolicLink()) {
|
|
62
|
+
out.push(part);
|
|
63
|
+
q = q.slice(1);
|
|
64
|
+
}
|
|
65
|
+
else if (depth++ > 100) {
|
|
66
|
+
out.push(part, ...q.slice(1)); // cycle: give up rather than loop
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readlinkSync(here); // a relative target is relative to the link's dir
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
out.push(part, ...q.slice(1)); // unreadable: treat as the not-yet-existing tail
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// The target, absolute-resolved, becomes the whole remaining path to peel: the link's
|
|
79
|
+
// own directory is dropped and replaced by wherever the link points.
|
|
80
|
+
const target = path.isAbsolute(raw) ? raw : path.resolve(path.join(path.sep, ...out), raw);
|
|
81
|
+
q = [...target.split(path.sep).filter(Boolean), ...q.slice(1)];
|
|
82
|
+
out.length = 0;
|
|
47
83
|
}
|
|
48
84
|
}
|
|
85
|
+
return path.join(path.sep, ...out);
|
|
49
86
|
}
|
|
50
87
|
/** A tool's path argument as pi's tools resolve it (pi `resolveToCwd`): a leading `@` dropped,
|
|
51
88
|
* `~` expanded to the home directory, and a relative path taken from `base`. */
|
|
@@ -319,10 +319,9 @@ function archiveCommitWhat(plan) {
|
|
|
319
319
|
}
|
|
320
320
|
/** One live backlog file's finished-history rows as liveFinishedHistory reports them, phrased
|
|
321
321
|
* and joined: `file ## <section> holds N finished entries (X KB)` rows separated by "; " — the
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* explanation, so the two texts stay byte-identical up to that suffix. */
|
|
322
|
+
* detail head the doctor's `backlog archive` check (checkLiveFinished) renders, so the row
|
|
323
|
+
* shape, the plural, and the KB rounding stay stable. Each caller appends its own trailing
|
|
324
|
+
* explanation. */
|
|
326
325
|
export function liveFinishedRowList(rows) {
|
|
327
326
|
return rows.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`).join("; ");
|
|
328
327
|
}
|