toolroll 0.9.7 → 0.9.8
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/CHANGELOG.md +26 -1
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-adapters.js +2 -0
- package/dist/assignment.d.ts +6 -0
- package/dist/assignment.js +18 -3
- package/dist/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -0
- package/dist/check-levels-ui.d.ts +5 -0
- package/dist/check-levels-ui.js +16 -2
- package/dist/cli.js +2 -0
- package/dist/dispatch.js +2 -1
- package/dist/dispose.d.ts +4 -3
- package/dist/dispose.js +12 -4
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +1 -1
- package/dist/invoke.js +5 -0
- package/dist/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- package/dist/operate.js +119 -22
- package/dist/process-custody.js +8 -0
- package/dist/provider.js +16 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- package/dist/serve.js +29 -4
- package/dist/store.d.ts +36 -1
- package/dist/store.js +136 -5
- package/dist/surface.js +5 -1
- package/dist/task-status.d.ts +5 -2
- package/dist/task-status.js +10 -6
- package/dist/work-summary.js +2 -1
- package/dist/workspace-ui.d.ts +3 -1
- package/dist/workspace-ui.js +9 -8
- package/package.json +1 -1
package/dist/dispose.js
CHANGED
|
@@ -17,7 +17,9 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { completeFenced, finalizeFailureFenced, finalizeHandBackFenced, finalizeMalformedFenced, finalizeParkFenced, interruptIfStopped, release, } from "./claim.js";
|
|
18
18
|
import { bodyHashOf, publicationBody } from "./publish.js";
|
|
19
19
|
import { modeTermsFromJson } from "./modes.js";
|
|
20
|
-
import { readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
|
|
20
|
+
import { evidenceRoot, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
|
|
21
|
+
import { queueBuildReview } from "./build-review.js";
|
|
22
|
+
import { homedir } from "node:os";
|
|
21
23
|
import {} from "./proof.js";
|
|
22
24
|
import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
|
|
23
25
|
import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
|
|
@@ -55,9 +57,14 @@ const STANDALONE_BROKE_REASONS = new Set([
|
|
|
55
57
|
"timeout",
|
|
56
58
|
"git",
|
|
57
59
|
]);
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
60
|
+
/** A committed build whose approved check passed gets the project's one
|
|
61
|
+
* automatic review when its review switch is on (build-review.ts). No-change
|
|
62
|
+
* results and failed checks go straight to the person, as before. */
|
|
63
|
+
export function maybeRequestAutoReview(store, repo, runId, committed, noChange, now, root = evidenceRoot(homedir())) {
|
|
64
|
+
if (!committed || noChange)
|
|
65
|
+
return;
|
|
66
|
+
queueBuildReview(store, repo, runId, root, now);
|
|
67
|
+
}
|
|
61
68
|
export function disposeBuildOutcome(context, result) {
|
|
62
69
|
// Stop and every terminal side effect compete under the same SQLite
|
|
63
70
|
// write lock. A separate preliminary transaction leaves a completion gap.
|
|
@@ -238,6 +245,7 @@ function disposeBuildOutcomeLocked(context, result) {
|
|
|
238
245
|
// proves the credential, clearing any quota stamp.
|
|
239
246
|
store.resetStrikes(taskRef);
|
|
240
247
|
store.clearQuota(runner, provider, model ?? "");
|
|
248
|
+
maybeRequestAutoReview(store, repo, runId, result.committed, result.noChange === true, clock(), context.evidenceRoot);
|
|
241
249
|
return { kind: "built", committed: result.committed, noChange: result.noChange === true };
|
|
242
250
|
}
|
|
243
251
|
store.transact(() => {
|
package/dist/exec.d.ts
CHANGED
|
@@ -113,6 +113,11 @@ export type RunOptions = ProcessTreeObserver & {
|
|
|
113
113
|
/** Fires once the child exists, with its pid (the process-group id when
|
|
114
114
|
* processGroup is set) — the slot ledger records it (v14 finding 26). */
|
|
115
115
|
onSpawn?: (pid: number) => void;
|
|
116
|
+
/** Fires instead of onSpawn when a spawn that beforeSpawn admitted made no
|
|
117
|
+
* process at all (the OS call threw — ENOENT, EAGAIN — returned no pid, or
|
|
118
|
+
* its OS object could not be made), so its reserved witness can be settled
|
|
119
|
+
* as never started in the same step. Never fires once a child exists. */
|
|
120
|
+
onSpawnFailed?: () => void;
|
|
116
121
|
/** Fires before any target spawn, so a crash cannot lose its OS object. */
|
|
117
122
|
onContainer?: (info: {
|
|
118
123
|
backend: ContainmentBackendId;
|
package/dist/exec.js
CHANGED
|
@@ -84,9 +84,24 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
84
84
|
throw new ContainmentRefusal(effective.refusal);
|
|
85
85
|
if (bag.beforeSpawn?.() === false)
|
|
86
86
|
throw new Error("the attempt stopped before this process could spawn");
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// From here a witness may be reserved: every road that makes no process
|
|
88
|
+
// says so through onSpawnFailed before it returns or throws.
|
|
89
|
+
const neverStarted = () => { try {
|
|
90
|
+
bag.onSpawnFailed?.();
|
|
91
|
+
}
|
|
92
|
+
catch { /* the reservation stays unproven */ } };
|
|
93
|
+
let made;
|
|
94
|
+
try {
|
|
95
|
+
made = effective === null ? { container: null } : createContainer(effective, label);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
neverStarted();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if ("refused" in made) {
|
|
102
|
+
neverStarted();
|
|
89
103
|
throw new ContainmentRefusal(made.refused);
|
|
104
|
+
}
|
|
90
105
|
const container = made.container;
|
|
91
106
|
try {
|
|
92
107
|
if (container !== null)
|
|
@@ -94,6 +109,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
94
109
|
}
|
|
95
110
|
catch (error) {
|
|
96
111
|
container?.release();
|
|
112
|
+
neverStarted();
|
|
97
113
|
throw error;
|
|
98
114
|
}
|
|
99
115
|
let launch;
|
|
@@ -102,6 +118,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
102
118
|
}
|
|
103
119
|
catch (error) {
|
|
104
120
|
container?.release();
|
|
121
|
+
neverStarted();
|
|
105
122
|
throw error;
|
|
106
123
|
}
|
|
107
124
|
let child;
|
|
@@ -123,6 +140,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
123
140
|
}
|
|
124
141
|
catch { }
|
|
125
142
|
container?.release();
|
|
143
|
+
neverStarted();
|
|
126
144
|
throw error;
|
|
127
145
|
}
|
|
128
146
|
if (container !== null) {
|
|
@@ -143,6 +161,12 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
143
161
|
if (child.pid !== undefined) {
|
|
144
162
|
bag.onSpawn?.(child.pid);
|
|
145
163
|
}
|
|
164
|
+
else if (container === null) {
|
|
165
|
+
// No pid: the OS refused the spawn (ENOENT, EACCES) and only an
|
|
166
|
+
// 'error' event follows. A contained spawn settles once its object is
|
|
167
|
+
// proven empty, and its transport's return finishes the witness.
|
|
168
|
+
neverStarted();
|
|
169
|
+
}
|
|
146
170
|
}
|
|
147
171
|
catch (error) {
|
|
148
172
|
throw new SpawnCustodyFailure(child, error);
|
|
@@ -201,7 +225,7 @@ function containmentOf(container, empty, attachFailure) {
|
|
|
201
225
|
}
|
|
202
226
|
/** The streaming transports' spawn: contained when the spawn is a provider (process group), plain otherwise. */
|
|
203
227
|
function spawnStream(file, args, options, stdio, childEnv) {
|
|
204
|
-
return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
|
|
228
|
+
return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onSpawnFailed: options.onSpawnFailed, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
|
|
205
229
|
}
|
|
206
230
|
/** The refusal-before-spawn shape shared by every containable transport. */
|
|
207
231
|
function refusedResult(error) {
|
|
@@ -506,6 +530,7 @@ export function run(file, args, options = {}) {
|
|
|
506
530
|
...(cwd === undefined ? {} : { cwd }),
|
|
507
531
|
...(childEnv === undefined ? {} : { childEnv }),
|
|
508
532
|
...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
|
|
533
|
+
...(options.onSpawnFailed === undefined ? {} : { onSpawnFailed: options.onSpawnFailed }),
|
|
509
534
|
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
510
535
|
...(options.beforeSpawn === undefined ? {} : { beforeSpawn: options.beforeSpawn }),
|
|
511
536
|
...(options.onStdout === undefined ? {} : { onStdout: options.onStdout }),
|
|
@@ -553,7 +578,7 @@ function runBufferedGroup(file, args, bag) {
|
|
|
553
578
|
let container = null;
|
|
554
579
|
let attached;
|
|
555
580
|
try {
|
|
556
|
-
const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
|
|
581
|
+
const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onSpawnFailed: bag.onSpawnFailed, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
|
|
557
582
|
child = spawned.child;
|
|
558
583
|
container = spawned.container;
|
|
559
584
|
attached = spawned.attached;
|
|
@@ -1578,6 +1603,7 @@ export function startClaudeHeldSession(file, args, options) {
|
|
|
1578
1603
|
}, options.owner ?? "held", {
|
|
1579
1604
|
beforeSpawn: options.beforeSpawn,
|
|
1580
1605
|
onSpawn: options.onSpawn,
|
|
1606
|
+
onSpawnFailed: options.onSpawnFailed,
|
|
1581
1607
|
onContainer: options.onContainer,
|
|
1582
1608
|
onContainerEmpty: options.onContainerEmpty,
|
|
1583
1609
|
onUnknown: options.onUnknown,
|
package/dist/first-run.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export type JourneyStep = {
|
|
|
88
88
|
* the build finished with a result.
|
|
89
89
|
*/
|
|
90
90
|
export declare function firstTaskJourney(read: {
|
|
91
|
-
stage: "queued" | "planning" | "needs-you" | "building" | "checking" | "finished" | "complete" | "failed" | "stopped";
|
|
91
|
+
stage: "queued" | "planning" | "needs-you" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
|
|
92
92
|
need?: string | undefined;
|
|
93
93
|
}, approved: boolean, built?: boolean): JourneyStep[];
|
|
94
94
|
export {};
|
package/dist/first-run.js
CHANGED
|
@@ -129,7 +129,7 @@ export const JOURNEY_STEPS = [["plan", "Plan"], ["approve", "You approve"], ["bu
|
|
|
129
129
|
*/
|
|
130
130
|
export function firstTaskJourney(read, approved, built = false) {
|
|
131
131
|
const at = read.stage === "finished" || read.stage === "complete" ? { index: 5, stuck: false }
|
|
132
|
-
: read.stage === "checking" ? { index: 3, stuck: false }
|
|
132
|
+
: read.stage === "checking" || read.stage === "reviewing" ? { index: 3, stuck: false }
|
|
133
133
|
: read.stage === "building" ? { index: 2, stuck: false }
|
|
134
134
|
: read.stage === "needs-you" && read.need === "approval" ? { index: 1, stuck: false }
|
|
135
135
|
: read.stage === "planning" ? { index: 0, stuck: false }
|
package/dist/invoke.js
CHANGED
|
@@ -646,6 +646,11 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
|
|
|
646
646
|
store.recordRunProcess(runId, pid, clock(), false, heldWitness);
|
|
647
647
|
runOptions.onSpawn?.(pid);
|
|
648
648
|
},
|
|
649
|
+
onSpawnFailed: () => {
|
|
650
|
+
if (heldWitness !== undefined)
|
|
651
|
+
store.finishUnspawnedProcess(heldWitness, clock());
|
|
652
|
+
runOptions.onSpawnFailed?.();
|
|
653
|
+
},
|
|
649
654
|
onContainer: info => {
|
|
650
655
|
nativeHeld = true;
|
|
651
656
|
if (heldWitness !== undefined)
|
package/dist/modes.d.ts
CHANGED
|
@@ -29,7 +29,9 @@ export type ModeTerms = {
|
|
|
29
29
|
planAuto: boolean;
|
|
30
30
|
/** Attended mint without the per-mint password — signer only (D8). */
|
|
31
31
|
quickMint: boolean;
|
|
32
|
-
/** Historical signed field; new modes leave it false and no worker consumes
|
|
32
|
+
/** Historical signed field; new modes leave it false and no worker consumes
|
|
33
|
+
* it. Automatic review is the project's review switch (review-switch.ts),
|
|
34
|
+
* on by default while a hands-off mode is active. */
|
|
33
35
|
reviewAuto: boolean;
|
|
34
36
|
/** Historical signed retry term, retained for digest and audit compatibility. */
|
|
35
37
|
reviewRetryAuto: boolean;
|
package/dist/modes.js
CHANGED
|
@@ -157,7 +157,9 @@ export function modeWords(terms) {
|
|
|
157
157
|
terms.planAuto
|
|
158
158
|
? "plans for your pre-authorized filings auto-approve only when the goal, exclusions, paths, acceptance criteria, risk, budget, and agent route remain exactly unchanged; provide a goal, paths and acceptance criteria upfront; amendments and unresolved questions still wait for you"
|
|
159
159
|
: "planner-generated plans wait for your approval",
|
|
160
|
-
|
|
160
|
+
terms.name === "hands-off"
|
|
161
|
+
? "automatic review is on for this project unless you turn it off (`toolroll review off --repo <path>`): each finished build whose check passes (or that finishes, with checks Off) gets one read-only review by the project's review agent; a HIGH finding sends it back once as a revision filed under this mode, a HIGH on that revision comes to you, MEDIUM and LOW findings come to you as suggested follow-ups, and a review that fails or times out never holds the work — it reaches you marked not reviewed"
|
|
162
|
+
: "finished work and saved checks go to the lead or user; automatic review runs only where a project turns it on (`toolroll review on --repo <path>`)",
|
|
161
163
|
...(terms.reviewAuto || terms.reviewRetryAuto
|
|
162
164
|
? ["historical review grants are retained on record but no longer schedule work"] : []),
|
|
163
165
|
terms.perAttemptBudgetMicrousd === null
|
package/dist/operate.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
2
2
|
import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
|
|
3
3
|
import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
|
|
4
|
+
import { buildReviewPass } from "./build-review.js";
|
|
5
|
+
import { buildReviewLines, buildReviewOf } from "./review-switch.js";
|
|
4
6
|
import { parseProtectedPaths } from "./approval-policy.js";
|
|
5
7
|
import { rulesSummary } from "./approval-rules-ui.js";
|
|
6
8
|
import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
|
|
7
9
|
import { matchesOutsideCheckpoint } from "./ledger-chain.js";
|
|
8
10
|
import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
|
|
9
11
|
import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
|
|
10
|
-
import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
|
|
12
|
+
import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
|
|
11
13
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
12
14
|
import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
|
|
13
15
|
import { spendCsv } from "./spend-ui.js";
|
|
@@ -239,9 +241,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
239
241
|
toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
|
|
240
242
|
toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
|
|
241
243
|
toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
|
|
242
|
-
toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
|
|
244
|
+
toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
|
|
243
245
|
toolroll backup now|list back the database up now; list backups and how the last ones went
|
|
244
246
|
toolroll restore <file> [--dry-run] put a backup back (Toolroll stopped; the current database is kept)
|
|
247
|
+
toolroll review show|on|off --repo <path> one automatic review of each finished build; only HIGH findings send it back
|
|
245
248
|
toolroll policy show|set the organisation policy; an instance operator sets it with --providers claude,codex|any,
|
|
246
249
|
--models <m,…>|any, --tools <t,…>|any, --ceiling safe|standard|escalated
|
|
247
250
|
toolroll ledger verify check the action ledger's hash chain (--checkpoint <n:hash> to compare a copied head)
|
|
@@ -932,6 +935,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
932
935
|
return storageCommand(positional, flags, context);
|
|
933
936
|
case "integrations":
|
|
934
937
|
return integrationsCommand(positional, flags, context);
|
|
938
|
+
case "review":
|
|
939
|
+
return reviewSwitchCommand(positional, flags, context);
|
|
935
940
|
case "monitoring": {
|
|
936
941
|
// Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
|
|
937
942
|
const settings = readMonitoring(dirname(context.databaseFile));
|
|
@@ -992,6 +997,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
992
997
|
return tickCommand(flags, context);
|
|
993
998
|
case "reconcile":
|
|
994
999
|
return reconcileCommand(flags, context);
|
|
1000
|
+
case "run":
|
|
1001
|
+
return runCommand(positional, flags, context);
|
|
995
1002
|
case "cap":
|
|
996
1003
|
return capCommand(positional, flags, context);
|
|
997
1004
|
case "gaps":
|
|
@@ -4277,19 +4284,20 @@ async function tickCommand(flags, context) {
|
|
|
4277
4284
|
broke++;
|
|
4278
4285
|
}
|
|
4279
4286
|
}
|
|
4280
|
-
//
|
|
4281
|
-
//
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
}
|
|
4291
|
-
}
|
|
4287
|
+
// The one automatic review each finished build gets when its project's
|
|
4288
|
+
// switch is on, before the result is handed on below. Other queued review
|
|
4289
|
+
// asks close unrun. Reauthenticated first; only admitted projects.
|
|
4290
|
+
if (!buildsOnly && authenticate(store, runner, token).ok) {
|
|
4291
|
+
const reviewed = await buildReviewPass(store, {
|
|
4292
|
+
runner, token, repos: auth.runner.repos, clock,
|
|
4293
|
+
root: context.evidenceRoot,
|
|
4294
|
+
...(text(flags, "incarnation") === undefined ? {} : { watchIncarnation: text(flags, "incarnation") }),
|
|
4295
|
+
...(context.agentRunner === undefined ? {} : { agent: context.agentRunner }),
|
|
4296
|
+
shouldStop: () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true,
|
|
4292
4297
|
});
|
|
4298
|
+
for (const one of reviewed)
|
|
4299
|
+
dispatched.push({ id: `review of run ${one.run}`, outcome: one.outcome === "not-reviewed" ? "not-reviewed" : "reviewed", reason: one.detail });
|
|
4300
|
+
}
|
|
4293
4301
|
// Existing worker pass owns durable lead handoffs; reads never create events.
|
|
4294
4302
|
if (!buildsOnly)
|
|
4295
4303
|
syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
|
|
@@ -4327,7 +4335,7 @@ async function tickCommand(flags, context) {
|
|
|
4327
4335
|
routines,
|
|
4328
4336
|
});
|
|
4329
4337
|
}
|
|
4330
|
-
if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held")) {
|
|
4338
|
+
if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held" || one.outcome === "reviewed" || one.outcome === "not-reviewed")) {
|
|
4331
4339
|
return succeed(write, json, "tick", { considered, dispatched, routines, ...flows }, summary);
|
|
4332
4340
|
}
|
|
4333
4341
|
if (considered === 0) {
|
|
@@ -4389,6 +4397,9 @@ async function reconcileCommand(flags, context) {
|
|
|
4389
4397
|
}
|
|
4390
4398
|
}
|
|
4391
4399
|
const recovered = recoverDead(store, clock());
|
|
4400
|
+
// A witness reserved for a spawn that never made a process (a crash or a
|
|
4401
|
+
// thrown spawn) settles once its run's process groups are proven gone.
|
|
4402
|
+
store.settleUnspawnedWitnesses(clock());
|
|
4392
4403
|
store.settleQuiescentStops(clock());
|
|
4393
4404
|
for (const one of recovered) {
|
|
4394
4405
|
for (const leaseId of one.claims) {
|
|
@@ -5045,13 +5056,18 @@ async function planTaskCommand(positional, flags, context) {
|
|
|
5045
5056
|
}
|
|
5046
5057
|
/** The retired command is a typed refusal, never a new provider request. */
|
|
5047
5058
|
async function reviewTaskCommand(positional, flags, context) {
|
|
5048
|
-
const { store, write, json
|
|
5059
|
+
const { store, write, json } = context;
|
|
5049
5060
|
const [runText] = positional;
|
|
5050
5061
|
const runId = Number(runText ?? "");
|
|
5051
5062
|
if (runText === undefined || !Number.isInteger(runId) || runId < 1) {
|
|
5052
5063
|
return fail(write, json, "task review", "usage", "`toolroll task review <run-id>`", EXIT.usage);
|
|
5053
5064
|
}
|
|
5054
|
-
|
|
5065
|
+
// Read-only: the run's one automatic review, when it had one.
|
|
5066
|
+
const review = buildReviewOf(store, runId);
|
|
5067
|
+
if (review !== null) {
|
|
5068
|
+
return succeed(write, json, "task review", { run: runId, review }, () => [`Run ${runId}`, ...buildReviewLines(review)]);
|
|
5069
|
+
}
|
|
5070
|
+
return fail(write, json, "task review", "model-review-retired", `Run ${runId} has no automatic review. Manual review requests have been removed; a project's builds are reviewed automatically when its switch is on (\`toolroll review on --repo <path>\`). Inspect the saved work and checks, then give feedback or request a revision.`, EXIT.refused);
|
|
5055
5071
|
}
|
|
5056
5072
|
/**
|
|
5057
5073
|
* `toolroll incident list|resolve <id>` — the parks that never became
|
|
@@ -10147,6 +10163,9 @@ function showTask(positional, context) {
|
|
|
10147
10163
|
// v50: the latest build's bounded review history — every root attempt
|
|
10148
10164
|
// in order, the open request, and the one state they add up to.
|
|
10149
10165
|
review: latestFinished === null ? null : store.reviewRetryStateOf(latestFinished.id),
|
|
10166
|
+
// The one automatic review of the latest build: HIGH findings, suggested
|
|
10167
|
+
// follow-ups (MEDIUM/LOW), or why it was not reviewed.
|
|
10168
|
+
automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
|
|
10150
10169
|
dispatch: diagnoseTaskDispatch(store, id, now),
|
|
10151
10170
|
// v52: the exact-run control the console shows — Stop, Stopping,
|
|
10152
10171
|
// Paused (resume), or the review-retry door — and every stop on record.
|
|
@@ -10199,7 +10218,7 @@ function showTask(positional, context) {
|
|
|
10199
10218
|
: detail.control.kind === "paused"
|
|
10200
10219
|
? [` control: paused — run #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} (${detail.control.stop.settlement ?? "?"})${detail.control.committed ? "; its commit is on the branch" : ""}; work preserved${detail.control.worktree === null ? "" : ` in ${detail.control.worktree}`} — \`task resume ${task.id} --run ${detail.control.run} --as <you> --token <t>\``]
|
|
10201
10220
|
: [` control: review #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} — inspect its saved result and give feedback; separate model review has been removed`]),
|
|
10202
|
-
...reviewStatusLines(detail.review, latestFinished?.id ?? null),
|
|
10221
|
+
...(detail.automaticReview === null ? reviewStatusLines(detail.review, latestFinished?.id ?? null) : buildReviewLines(detail.automaticReview)),
|
|
10203
10222
|
...(scope === null
|
|
10204
10223
|
? [" no scope — nothing will build this until one is written and approved"]
|
|
10205
10224
|
: describeScope(scope, readiness)),
|
|
@@ -11662,6 +11681,48 @@ async function resumeTaskCommand(positional, flags, context) {
|
|
|
11662
11681
|
: ` Not starting yet — ${resumed.gate.summary}: ${resumed.gate.detail}`,
|
|
11663
11682
|
]);
|
|
11664
11683
|
}
|
|
11684
|
+
/**
|
|
11685
|
+
* `toolroll run settle <id> --why "<text>" --as <you> --token <t>`: an
|
|
11686
|
+
* approver's last resort for a finished run whose process witnesses can be
|
|
11687
|
+
* proven neither exited nor alive (a spawn interrupted before its pid was
|
|
11688
|
+
* written). Refuses while anything of the run is alive; the reason is kept
|
|
11689
|
+
* in the ledger against the approver's name. Signals nothing.
|
|
11690
|
+
*/
|
|
11691
|
+
async function runCommand(positional, flags, context) {
|
|
11692
|
+
const { store, write, json, clock } = context;
|
|
11693
|
+
const [action, id, ...extra] = positional;
|
|
11694
|
+
const command = "run settle";
|
|
11695
|
+
const usage = "`toolroll run settle <run-id> --why \"<reason>\" --as <you> --token <t>`";
|
|
11696
|
+
if (action !== "settle")
|
|
11697
|
+
return fail(write, json, "run", "usage", usage, EXIT.usage);
|
|
11698
|
+
const runId = Number(id ?? "");
|
|
11699
|
+
const why = text(flags, "why")?.trim();
|
|
11700
|
+
if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
|
|
11701
|
+
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
11702
|
+
if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why)) {
|
|
11703
|
+
return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
|
|
11704
|
+
}
|
|
11705
|
+
const run = store.getRun(runId);
|
|
11706
|
+
if (run === null)
|
|
11707
|
+
return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
|
|
11708
|
+
const acting = await askCredentials(flags, context);
|
|
11709
|
+
if (acting === null) {
|
|
11710
|
+
return fail(write, json, command, "usage", "settling takes `--as <you> --token <t>` — it is an approver's act and is recorded against your name", EXIT.usage);
|
|
11711
|
+
}
|
|
11712
|
+
const authenticated = authenticateApprover(store, acting.name, acting.token);
|
|
11713
|
+
const repo = store.refById(run.taskRef)?.repo ?? null;
|
|
11714
|
+
if (!authenticated.ok || !store.accountCanAccess(acting.name, repo)) {
|
|
11715
|
+
return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
|
|
11716
|
+
}
|
|
11717
|
+
const settled = store.settleRunWitnessesByApprover({ runId, by: acting.name, why }, clock());
|
|
11718
|
+
if (!settled.ok)
|
|
11719
|
+
return fail(write, json, command, settled.reason, settled.detail, EXIT.refused, { run: runId });
|
|
11720
|
+
return succeed(write, json, command, { run: runId, witnesses: settled.witnesses, repeated: settled.repeated }, () => [
|
|
11721
|
+
settled.repeated
|
|
11722
|
+
? `Run #${runId} has no unsettled process witness; nothing changed.`
|
|
11723
|
+
: `Settled run #${runId}: ${settled.witnesses.length} process witness${settled.witnesses.length === 1 ? "" : "es"} recorded as ended, with your reason in the ledger.`,
|
|
11724
|
+
]);
|
|
11725
|
+
}
|
|
11665
11726
|
// ---- shared ---------------------------------------------------------------
|
|
11666
11727
|
/**
|
|
11667
11728
|
* `project rules --repo <path>` shows a project's approval rules (v102);
|
|
@@ -12026,9 +12087,11 @@ async function retentionCommand(positional, flags, context) {
|
|
|
12026
12087
|
const label = (kind) => RETENTION_KINDS.find(one => one.kind === kind).label;
|
|
12027
12088
|
if (action === undefined || action === "show") {
|
|
12028
12089
|
const periods = store.retentionPeriods();
|
|
12090
|
+
const chosen = store.retentionChosen();
|
|
12091
|
+
const defaulted = RETENTION_KINDS.map(one => one.kind).filter(kind => !(kind in chosen));
|
|
12029
12092
|
const last = lastSweepAt(store);
|
|
12030
|
-
return succeed(context.write, context.json, command, { periods, lastSweep: last }, () => [
|
|
12031
|
-
...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${
|
|
12093
|
+
return succeed(context.write, context.json, command, { periods, defaulted, lastSweep: last }, () => [
|
|
12094
|
+
...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodLabel(one.kind, periods[one.kind], one.kind in chosen).toLowerCase().padEnd(18)} ${one.label}`),
|
|
12032
12095
|
last === null ? "No sweep yet; the worker runs one a day." : `Last sweep ${last.slice(0, 16).replace("T", " ")} UTC.`,
|
|
12033
12096
|
]);
|
|
12034
12097
|
}
|
|
@@ -12044,11 +12107,11 @@ async function retentionCommand(positional, flags, context) {
|
|
|
12044
12107
|
return fail(context.write, context.json, command, "usage", "Use retention show, retention preview or retention set <kind> <period>.", EXIT.usage);
|
|
12045
12108
|
const [kind, period, ...extra] = rest;
|
|
12046
12109
|
if (kind === undefined || !isRetentionKind(kind) || period === undefined || extra.length > 0) {
|
|
12047
|
-
return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 90d or 1y, or forever>.`, EXIT.usage);
|
|
12110
|
+
return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 1d, 90d or 1y, or forever>.`, EXIT.usage);
|
|
12048
12111
|
}
|
|
12049
12112
|
const days = parsePeriod(period);
|
|
12050
12113
|
if (days === undefined)
|
|
12051
|
-
return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS}
|
|
12114
|
+
return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} day to 10 years (1d, 90d, 12w, 1y).`, EXIT.usage);
|
|
12052
12115
|
const acting = await askCredentials(flags, context);
|
|
12053
12116
|
const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
|
|
12054
12117
|
if (acting === null || verified === null || !verified.ok || !store.isInstanceOperator(acting.name)) {
|
|
@@ -12148,6 +12211,40 @@ async function storageCommand(positional, flags, context) {
|
|
|
12148
12211
|
function checkoutSummary(plan) {
|
|
12149
12212
|
return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
|
|
12150
12213
|
}
|
|
12214
|
+
/** `review [show] | on | off --repo <path>`: the project's one automatic review
|
|
12215
|
+
* of finished builds. Changing it takes an approver of that project; the
|
|
12216
|
+
* policy log keeps before → after. */
|
|
12217
|
+
async function reviewSwitchCommand(positional, flags, context) {
|
|
12218
|
+
const { store, write, json } = context;
|
|
12219
|
+
const [action = "show", ...extra] = positional;
|
|
12220
|
+
const command = `review ${action}`;
|
|
12221
|
+
if (extra.length > 0 || !["show", "on", "off"].includes(action))
|
|
12222
|
+
return fail(write, json, command, "usage", "Use toolroll review show|on|off --repo <path>.", EXIT.usage);
|
|
12223
|
+
const allowed = new Set(["repo", "db", "json", ...(action === "show" ? [] : ["as", "token", "token-file", "token-env"])]);
|
|
12224
|
+
for (const name of flags.keys())
|
|
12225
|
+
if (!allowed.has(name))
|
|
12226
|
+
return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
|
|
12227
|
+
const given = text(flags, "repo");
|
|
12228
|
+
if (given === undefined)
|
|
12229
|
+
return fail(write, json, command, "usage", "Name the project: toolroll review " + action + " --repo <path>.", EXIT.usage);
|
|
12230
|
+
const repo = canonicalProject(given) ?? resolve(given);
|
|
12231
|
+
const words = (on, source) => `${projectName(repo)}: automatic review is ${on ? "on" : "off"}${source === "hands-off" ? " (on while the hands-off mode lasts)" : source === "default" ? " (the default)" : ""}.`;
|
|
12232
|
+
if (action === "show") {
|
|
12233
|
+
const state = store.reviewSwitch(repo, context.clock());
|
|
12234
|
+
return succeed(write, json, command, { repo, ...state }, () => [words(state.on, state.source)]);
|
|
12235
|
+
}
|
|
12236
|
+
const acting = await askCredentials(flags, context);
|
|
12237
|
+
if (acting === null)
|
|
12238
|
+
return fail(write, json, command, "usage", `Sign in first, or pass --as <you> --token <t>: toolroll review ${action} --repo <path>.`, EXIT.usage);
|
|
12239
|
+
const verified = authenticateApprover(store, acting.name, acting.token, repo);
|
|
12240
|
+
if (!verified.ok)
|
|
12241
|
+
return fail(write, json, command, verified.reason, `Only an approver of ${projectName(repo)} can change its review switch.`, EXIT.refused);
|
|
12242
|
+
const changed = store.setReviewSwitch(repo, action === "on", acting.name, context.clock());
|
|
12243
|
+
return succeed(write, json, command, { repo, ...changed.after, before: changed.before }, () => [
|
|
12244
|
+
words(changed.after.on, changed.after.source),
|
|
12245
|
+
changed.after.on ? "Each finished build whose check passes gets one read-only review; only HIGH findings send it back, once." : "Finished builds go straight to you.",
|
|
12246
|
+
]);
|
|
12247
|
+
}
|
|
12151
12248
|
/** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
|
|
12152
12249
|
* that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
|
|
12153
12250
|
async function policyCommand(positional, flags, context) {
|
package/dist/process-custody.js
CHANGED
|
@@ -69,6 +69,14 @@ export function witnessedRunner(store, runId, clock, runner) {
|
|
|
69
69
|
store.recordRunProcess(runId, pid, clock(), options.processGroup === true, witnesses.at(-1));
|
|
70
70
|
options.onSpawn?.(pid);
|
|
71
71
|
},
|
|
72
|
+
// The spawn made no process (ENOENT, EAGAIN, a throw): its reservation
|
|
73
|
+
// settles as never started now, not only if the transport returns.
|
|
74
|
+
onSpawnFailed: () => {
|
|
75
|
+
const witness = witnesses.at(-1);
|
|
76
|
+
if (witness !== undefined)
|
|
77
|
+
store.finishUnspawnedProcess(witness, clock());
|
|
78
|
+
options.onSpawnFailed?.();
|
|
79
|
+
},
|
|
72
80
|
onContainer: info => {
|
|
73
81
|
native = true;
|
|
74
82
|
const witness = witnesses.at(-1);
|
package/dist/provider.js
CHANGED
|
@@ -169,6 +169,22 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
|
|
|
169
169
|
additionalProperties: false,
|
|
170
170
|
},
|
|
171
171
|
},
|
|
172
|
+
// The automatic build review's reply (build-review.ts). Shape only;
|
|
173
|
+
// parseBuildFindings is the validator.
|
|
174
|
+
findings: {
|
|
175
|
+
type: "array",
|
|
176
|
+
items: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: {
|
|
179
|
+
severity: { type: "string", enum: ["HIGH", "MEDIUM", "LOW"] },
|
|
180
|
+
file: { type: "string" },
|
|
181
|
+
line: { type: "integer", minimum: 1 },
|
|
182
|
+
scenario: { type: "string" },
|
|
183
|
+
},
|
|
184
|
+
required: ["severity", "file", "line", "scenario"],
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
172
188
|
// The read request rides the same structured reply channel and
|
|
173
189
|
// session. The machine validates the exact allowlist/hash/range
|
|
174
190
|
// before supplying bytes; this only shapes the request.
|
package/dist/retention-ui.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { type RetentionCount, type RetentionPeriods } from "./retention.js";
|
|
1
|
+
import { type RetentionCount, type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
2
2
|
export declare const RETENTION_CSS: string;
|
|
3
|
+
/** `chosen`: the kinds someone chose a period for; the rest keep their default. */
|
|
3
4
|
export type RetentionView = {
|
|
4
5
|
periods: RetentionPeriods;
|
|
6
|
+
chosen: readonly RetentionKind[];
|
|
5
7
|
next: RetentionCount[];
|
|
6
8
|
lastSweep: string | null;
|
|
7
9
|
csrf: string;
|
package/dist/retention-ui.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* take a step-up.
|
|
5
5
|
*/
|
|
6
6
|
import { bytesWords } from "./storage.js";
|
|
7
|
-
import {
|
|
7
|
+
import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
|
|
8
8
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
9
9
|
export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
|
|
10
10
|
`.retention form{display:grid;gap:0}.retention .kind{display:grid;grid-template-columns:minmax(0,1fr) 11rem;gap:4px 16px;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}` +
|
|
@@ -26,8 +26,8 @@ export function retentionHtml(view, notice) {
|
|
|
26
26
|
const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
|
|
27
27
|
const rows = RETENTION_KINDS.map(({ kind, label, detail }) => {
|
|
28
28
|
const current = view.periods[kind];
|
|
29
|
-
const
|
|
30
|
-
const options =
|
|
29
|
+
const chosen = view.chosen.includes(kind);
|
|
30
|
+
const options = periodChoices(kind, current).map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${e(periodLabel(kind, days, chosen || days !== current))}</option>`).join("");
|
|
31
31
|
return `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${e(label)}</label><p class="meta">${e(detail)}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
|
|
32
32
|
}).join("");
|
|
33
33
|
const last = view.lastSweep === null ? "" : ` Last sweep ${e(view.lastSweep.slice(0, 10))}.`;
|
|
@@ -35,5 +35,5 @@ export function retentionHtml(view, notice) {
|
|
|
35
35
|
`<form method="post" action="/settings/retention"><input type="hidden" name="csrf" value="${e(view.csrf)}">${rows}` +
|
|
36
36
|
`<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
|
|
37
37
|
`<button type="submit">Save</button></form>` +
|
|
38
|
-
`<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results
|
|
38
|
+
`<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
|
|
39
39
|
}
|
package/dist/retention.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { Store } from "./store.js";
|
|
2
|
-
export declare const RETENTION_SCHEMA
|
|
1
|
+
import type { Database, Store } from "./store.js";
|
|
2
|
+
export declare const RETENTION_SCHEMA: string;
|
|
3
|
+
/** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
|
|
4
|
+
* every row, in one transaction. A file already widened is untouched. */
|
|
5
|
+
export declare function widenRetentionSchema(db: Database): void;
|
|
3
6
|
export type RetentionKind = "evidence" | "checkouts" | "chat" | "notifications";
|
|
4
7
|
export type RetentionPeriods = Record<RetentionKind, number | null>;
|
|
5
8
|
export declare const RETENTION_KINDS: readonly {
|
|
@@ -7,19 +10,23 @@ export declare const RETENTION_KINDS: readonly {
|
|
|
7
10
|
label: string;
|
|
8
11
|
detail: string;
|
|
9
12
|
}[];
|
|
10
|
-
|
|
11
|
-
export declare const
|
|
12
|
-
export declare const MIN_DAYS = 7;
|
|
13
|
+
export declare const PERIOD_CHOICES: Readonly<Record<RetentionKind, readonly (number | null)[]>>;
|
|
14
|
+
export declare const MIN_DAYS = 1;
|
|
13
15
|
export declare const MAX_DAYS = 3650;
|
|
14
16
|
/** A sweep is due once a day. */
|
|
15
17
|
export declare const SWEEP_EVERY_MS = 86400000;
|
|
16
18
|
/** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
|
|
17
19
|
export declare const SWEEP_BATCH = 2000;
|
|
18
|
-
|
|
20
|
+
/** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
|
|
21
|
+
export declare const DEFAULT_PERIODS: RetentionPeriods;
|
|
19
22
|
export declare function isRetentionKind(value: string): value is RetentionKind;
|
|
20
23
|
/** "forever", or a number of days ("90", "90d"), weeks ("12w") or years ("1y"); undefined when it's none of those or out of range. */
|
|
21
24
|
export declare function parsePeriod(text: string): number | null | undefined;
|
|
22
25
|
export declare function periodWords(days: number | null): string;
|
|
26
|
+
/** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
|
|
27
|
+
export declare function periodChoices(kind: RetentionKind, current: number | null): readonly (number | null)[];
|
|
28
|
+
/** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
|
|
29
|
+
export declare function periodLabel(kind: RetentionKind, days: number | null, chosen: boolean): string;
|
|
23
30
|
export type RetentionCount = {
|
|
24
31
|
kind: RetentionKind;
|
|
25
32
|
days: number | null;
|