toolroll 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js CHANGED
@@ -206,6 +206,9 @@ import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFol
206
206
  import { prometheusMetrics } from "./metrics.js";
207
207
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
208
208
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
209
+ import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
210
+ import { bytesWords, parseCleanup } from "./storage.js";
211
+ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
209
212
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
210
213
  import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
211
214
  import { latestVersionNow } from "./releases.js";
@@ -393,6 +396,8 @@ export function createDecisionServer(options) {
393
396
  }
394
397
  }
395
398
  const clock = options.clock ?? (() => new Date());
399
+ /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
400
+ const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
396
401
  const install = installMethod(options.installBin);
397
402
  /** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
398
403
  const updateFacts = () => {
@@ -1411,7 +1416,7 @@ export function createDecisionServer(options) {
1411
1416
  !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('request')) {
1412
1417
  const key = createHash('sha256').update(JSON.stringify([workspaceIncarnation, who.name,
1413
1418
  who.session.generation, who.session.csrf, who.session.project, who.session.projectRevision,
1414
- admissionList(), url.pathname + url.search])).digest('hex');
1419
+ admissionList(), url.pathname + url.search, localSignIn?.signedIn ?? null])).digest('hex');
1415
1420
  const revision = workspaceRevision.current(), now = clock();
1416
1421
  const prior = workspaceValidators.get(key);
1417
1422
  if (prior && prior.revision === revision && prior.expiresAt > now.getTime() && request.headers['if-none-match'] === prior.etag) {
@@ -1883,7 +1888,7 @@ export function createDecisionServer(options) {
1883
1888
  })),
1884
1889
  cancelledBlockers: cancelled,
1885
1890
  gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
1886
- wizard: await firstRunStepsNow(now),
1891
+ wizard: firstRunStepsNow(now),
1887
1892
  worker: (() => {
1888
1893
  // The one fact the inbox must never hide (install review): with
1889
1894
  // no worker answering, nothing here will ever build, and every
@@ -3377,7 +3382,7 @@ export function createDecisionServer(options) {
3377
3382
  }
3378
3383
  }
3379
3384
  // The first run belongs to the lead conversation, for someone who can act on it.
3380
- const firstRun = focusTask === null && who.role === "approver" ? await chatFirstRun(now, chatProject) : undefined;
3385
+ const firstRun = focusTask === null && who.role === "approver" ? chatFirstRun(now, chatProject) : undefined;
3381
3386
  const withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
3382
3387
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3383
3388
  {
@@ -3635,7 +3640,7 @@ export function createDecisionServer(options) {
3635
3640
  const current = options.updates?.current ?? PACKAGE_VERSION;
3636
3641
  // While update checks are off the page asks npm nothing until Check now.
3637
3642
  const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
3638
- const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, csrf: who.session.csrf };
3643
+ const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
3639
3644
  return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
3640
3645
  }
3641
3646
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
@@ -3645,6 +3650,15 @@ export function createDecisionServer(options) {
3645
3650
  const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3646
3651
  return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3647
3652
  }
3653
+ // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
3654
+ // finished task's clean checkout goes by itself. An instance operator's page.
3655
+ if (url.pathname === "/settings/storage") {
3656
+ const databaseFile = store.databaseFile();
3657
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
3658
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
3659
+ const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
3660
+ return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3661
+ }
3648
3662
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3649
3663
  if (url.pathname === "/settings/backups") {
3650
3664
  const databaseFile = store.databaseFile();
@@ -3865,39 +3879,67 @@ export function createDecisionServer(options) {
3865
3879
  * review, finding 14). Each step is either something this console can do,
3866
3880
  * or the exact command where only the CLI can (finding e).
3867
3881
  */
3868
- async function firstRunStepsNow(now) {
3882
+ function firstRunStepsNow(now) {
3869
3883
  if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
3870
3884
  return null;
3871
- return firstRunSteps({ agentSignedIn: await agentSignedIn(), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
3872
- }
3873
- /** Whether any coding agent on this computer is signed in: the same
3874
- * non-spending checks Settings makes, cached briefly. */
3875
- async function agentSignedIn() {
3876
- const states = await Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")));
3877
- return states.some(state => state === "connected" || state === "key-works" || state === "key-present");
3885
+ return firstRunSteps({ agentSignedIn: agentSignedIn(now), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
3878
3886
  }
3879
- /** First tasks per project, read at most every ten minutes: a `gh`
3880
- * round trip and a `git grep` never run on every render. */
3887
+ /**
3888
+ * Whether a coding agent is signed in where the work runs. This machine's
3889
+ * own checks (the ones Settings makes) speak only for a worker on this
3890
+ * machine, or for the first one while none is registered yet (it is started
3891
+ * here); a worker elsewhere speaks through its own readiness report, and
3892
+ * the console never claims to have checked that machine (finding 15).
3893
+ * Nothing here waits on a probe: this machine's answer is the last one
3894
+ * seen, refreshed in the background, and null until the first one is in.
3895
+ */
3896
+ function agentSignedIn(now) {
3897
+ const here = hostname();
3898
+ const registered = store.listRunners().filter(one => one.retiredAt === null);
3899
+ const workers = registered.filter(one => runnerAlive(one, now));
3900
+ if (workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready")))
3901
+ return true;
3902
+ return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
3903
+ }
3904
+ let localSignIn = null;
3905
+ let localSignInChecking = false;
3906
+ function localAgentSignedIn() {
3907
+ if (!localSignInChecking && (localSignIn === null || Date.now() - localSignIn.at > 30_000)) {
3908
+ localSignInChecking = true;
3909
+ void Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")))
3910
+ .then(states => { localSignIn = { at: Date.now(), signedIn: states.some(state => state === "connected" || state === "key-works" || state === "key-present") }; })
3911
+ .catch(() => { })
3912
+ .finally(() => { localSignInChecking = false; });
3913
+ }
3914
+ return localSignIn?.signedIn ?? null;
3915
+ }
3916
+ /** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
3917
+ * every ten minutes. A render never waits on `gh` or `git grep`. */
3881
3918
  const firstTasks = new Map();
3882
3919
  function firstTasksFor(repo) {
3883
- const at = Date.now(), hit = firstTasks.get(repo);
3884
- if (hit !== undefined && hit.until > at)
3885
- return hit.value;
3886
- const value = findFirstTasks(repo, options.firstTaskRunner ?? execRun).catch(() => firstTaskSuggestions({ issues: [], todos: [] }));
3887
- firstTasks.set(repo, { until: at + 10 * 60_000, value });
3888
- return value;
3920
+ const at = Date.now();
3921
+ let entry = firstTasks.get(repo);
3922
+ if (entry === undefined || (entry.until <= at && !entry.reading)) {
3923
+ const reading = { until: at + 10 * 60_000, found: entry?.found ?? null, reading: true };
3924
+ firstTasks.set(repo, reading);
3925
+ entry = reading;
3926
+ void findFirstTasks(repo, options.firstTaskRunner ?? execRun)
3927
+ .then(found => { reading.found = found; }, () => { })
3928
+ .finally(() => { reading.reading = false; });
3929
+ }
3930
+ return entry.found ?? firstTaskSuggestions({ issues: [], todos: [] });
3889
3931
  }
3890
3932
  /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
3891
- async function chatFirstRun(now, project) {
3892
- const steps = await firstRunStepsNow(now);
3933
+ function chatFirstRun(now, project) {
3934
+ const steps = firstRunStepsNow(now);
3893
3935
  if (steps === null)
3894
3936
  return undefined;
3895
3937
  const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
3896
3938
  const repo = project ?? managedRepos()[0] ?? null;
3897
3939
  return {
3898
3940
  steps,
3899
- suggestions: taskFiled || repo === null ? [] : await firstTasksFor(repo),
3900
- sandbox: steps.find(one => one.key === "agent")?.done === false ? SANDBOX_COMMAND : null,
3941
+ suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
3942
+ sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
3901
3943
  };
3902
3944
  }
3903
3945
  // ---- fleet chat (v13) ----------------------------------------------------
@@ -6555,8 +6597,8 @@ export function createDecisionServer(options) {
6555
6597
  if (!newerThan(version, current))
6556
6598
  return back("problem", `Toolroll ${version} is not newer than ${current}. To go back, run toolroll update --rollback.`);
6557
6599
  const method = options.updates?.method ?? installMethod();
6558
- if (method.kind === "npx" || method.kind === "source")
6559
- return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : "This runs from a source checkout. Update it with git.");
6600
+ if (method.kind === "npx" || method.kind === "source" || method.kind === "desktop")
6601
+ return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : method.kind === "desktop" ? "Update it from the Toolroll app." : "This runs from a source checkout. Update it with git.");
6560
6602
  const before = runtimeUpdateStatus(stateDir).journal;
6561
6603
  if (before !== null && !runtimeUpdateTerminal(before.phase))
6562
6604
  return back("problem", "An update is already under way.");
@@ -6594,6 +6636,38 @@ export function createDecisionServer(options) {
6594
6636
  store.setRetentionPeriod(one.kind, one.days ?? null, who.name, now);
6595
6637
  return back("said", changed.length === 0 ? "Nothing changed." : "Saved. The next daily sweep uses these periods.");
6596
6638
  }
6639
+ // Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
6640
+ // away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
6641
+ if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
6642
+ const databaseFile = store.databaseFile();
6643
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
6644
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
6645
+ const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
6646
+ const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
6647
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
6648
+ return back("problem", `Enter your Toolroll password to ${what}.`);
6649
+ if (url.pathname === "/settings/storage") {
6650
+ const cleanup = parseCleanup(body.get("cleanup") ?? "");
6651
+ if (cleanup === undefined)
6652
+ return back("problem", "Choose when a finished task's checkout is removed.");
6653
+ const before = store.checkoutCleanup();
6654
+ store.setCheckoutCleanup(cleanup, who.name, now);
6655
+ return back("said", cleanup === before ? "Nothing changed." : "Saved.");
6656
+ }
6657
+ const pool = storagePool(databaseFile);
6658
+ if (url.pathname.endsWith("/clean")) {
6659
+ // Only what the preview showed goes: when that changed since, the person looks again.
6660
+ const plan = await checkoutPlan(store, pool, clock(), { manual: true });
6661
+ if (plan.go.length === 0)
6662
+ return back("said", "Nothing to clean up.");
6663
+ if (body.get("preview") !== previewDigest(plan))
6664
+ return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
6665
+ const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
6666
+ return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
6667
+ }
6668
+ const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
6669
+ return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
6670
+ }
6597
6671
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6598
6672
  if (url.pathname === "/settings/backups/now") {
6599
6673
  const databaseFile = store.databaseFile();
@@ -14040,7 +14114,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14040
14114
  const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
14041
14115
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14042
14116
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14043
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14117
+ const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14044
14118
  /**
14045
14119
  * The board's liveness: fetch this page's own fragment on a timer and swap
14046
14120
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14699,7 +14773,7 @@ function inboxPage(chrome, data) {
14699
14773
  `<p><strong>Get to your first result</strong></p>` +
14700
14774
  data.wizard
14701
14775
  .map(step => `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong>` +
14702
- (step.action === null ? ` <span class="meta">done</span>`
14776
+ (step.checking ? ` <span class="meta">checking…</span>` : step.action === null ? ` <span class="meta">done</span>`
14703
14777
  : step.action.kind === "link" ? ` <a href="${escape(step.action.href)}">${escape(step.action.label)}</a>`
14704
14778
  : ` <code>${escape(step.action.command)}</code>`) +
14705
14779
  `</p>`)
@@ -21784,6 +21858,7 @@ const SETTINGS_TILE_ICONS = [
21784
21858
  ["/settings/approval", "Approval rules", `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
21785
21859
  ["/settings/monitoring", "Monitoring", `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
21786
21860
  ["/settings/retention", "Retention", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
21861
+ ["/settings/storage", "Storage", `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
21787
21862
  ["/settings/updates", "Updates", `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
21788
21863
  ["/settings/backups", "Backups", `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
21789
21864
  ["/settings/data", "Data", `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
@@ -0,0 +1,10 @@
1
+ import { type CheckoutPlan } from "./checkout-cleanup.js";
2
+ export declare const STORAGE_CSS: string;
3
+ export type StorageView = {
4
+ plan: CheckoutPlan;
5
+ csrf: string;
6
+ };
7
+ export declare function storageHtml(view: StorageView, notice: {
8
+ said?: string | null;
9
+ problem?: string | null;
10
+ }): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
3
+ * preview (what goes, what stays and why, with paths) sits behind the password, and when a finished task's clean
4
+ * checkout goes by itself. An instance operator's page.
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { basename } from "node:path";
8
+ import { previewDigest, whyWords } from "./checkout-cleanup.js";
9
+ import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
10
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
+ const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
12
+ export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
13
+ `.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
14
+ `.storage details.clean-up,.storage details.discard{border:0;padding:0;margin:0;background:transparent}` +
15
+ `.storage details.clean-up>summary{list-style:none;display:inline-flex;align-items:center;min-height:2.125rem;padding:.35rem .75rem;border-radius:calc(var(--radius) - 3px);` +
16
+ `background:var(--primary);color:var(--primary-foreground);font-weight:600;font-size:.8125rem;cursor:pointer;white-space:nowrap}` +
17
+ `.storage details.clean-up>summary::-webkit-details-marker{display:none}.storage details.clean-up[open]>summary{background:var(--card);color:var(--foreground);border:1px solid var(--input)}` +
18
+ `.storage .preview{display:grid;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)}.storage .preview h3{font-size:.875rem;margin:0}` +
19
+ `.storage ul.checkouts{list-style:none;padding:0;margin:0}` +
20
+ `.storage ul.checkouts li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:8px 0;border-top:1px solid var(--border);font-size:.8125rem}` +
21
+ `.storage ul.checkouts .name{font-weight:500;min-width:0}.storage ul.checkouts .size{font-variant-numeric:tabular-nums;white-space:nowrap}` +
22
+ `.storage ul.checkouts .path{grid-column:1/-1;font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem;color:var(--muted-foreground);word-break:break-all}` +
23
+ `.storage ul.checkouts .why{grid-column:1/-1}` +
24
+ `.storage details.discard{grid-column:1/-1}.storage details.discard>summary{cursor:pointer;color:var(--destructive);min-height:2.25rem;display:inline-flex;align-items:center}` +
25
+ `.storage form{display:grid;gap:10px;margin:0}.storage label{display:grid;gap:4px;font-size:.8125rem;font-weight:500;max-width:20rem}` +
26
+ `.storage select,.storage input[type=password]{width:100%;box-sizing:border-box;min-height:2.125rem}.storage form button{justify-self:start;white-space:nowrap}` +
27
+ `.storage form.setting button[type=submit],.storage .preview form.remove button[type=submit]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
28
+ `@media (max-width:900px){.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
29
+ function item(one, csrf) {
30
+ const discard = one.why === "has changes"
31
+ ? `<details class="discard"><summary>Discard</summary><form method="post" action="/settings/storage/discard"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="path" value="${e(one.path)}">` +
32
+ `<p class="meta">Throws away this checkout and its uncommitted changes. Its branch stays.</p>` +
33
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit" class="danger">Discard changes</button></form></details>`
34
+ : "";
35
+ // The task it was for, then where it is (the home folder as ~), then why it stays.
36
+ const home = homedir();
37
+ const where = home !== "" && one.path.startsWith(`${home}/`) ? `~${one.path.slice(home.length)}` : one.path;
38
+ return `<li data-checkout="${e(one.path)}"><span class="name">${e(one.taskId ?? basename(one.path))}</span><span class="size">${e(bytesWords(one.bytes))}</span>` +
39
+ `<span class="path">${e(where)}</span>` +
40
+ (one.why === null ? "" : `<span class="why">${e(whyWords(one)[0].toUpperCase() + whyWords(one).slice(1))}</span>`) + discard + `</li>`;
41
+ }
42
+ export function storageHtml(view, notice) {
43
+ const { plan, csrf } = view;
44
+ const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
45
+ const hidden = `<input type="hidden" name="csrf" value="${e(csrf)}">`;
46
+ const facts = [plural(plan.count, "checkout"), `${plan.waitingReview} waiting for review`, `${plan.withChanges} kept for their changes`].join(" · ");
47
+ const frees = plan.go.length === 0 ? "Nothing to clean up now." : `Cleaning up now frees about ${bytesWords(plan.freeBytes)}.`;
48
+ const remove = plan.go.length === 0 ? "" :
49
+ `<form method="post" action="/settings/storage/clean" class="remove">${hidden}<input type="hidden" name="preview" value="${e(previewDigest(plan))}">` +
50
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
51
+ `<button type="submit">Remove ${plural(plan.go.length, "checkout")}</button></form>`;
52
+ const preview = `<details class="clean-up" data-clean-up${notice.problem?.startsWith("Enter your Toolroll password to clean") ? " open" : ""}><summary>Clean up</summary><div class="preview">` +
53
+ (plan.go.length === 0 ? `<p>Nothing to remove now.</p>` :
54
+ `<h3>Removes ${plural(plan.go.length, "checkout")}, about ${e(bytesWords(plan.freeBytes))}</h3><p class="meta">Their branches stay, so nothing committed is lost.</p>` +
55
+ `<ul class="checkouts" data-goes>${plan.go.map(one => item(one, csrf)).join("")}</ul>`) +
56
+ (plan.stay.length === 0 ? "" : `<h3>Stays (${plan.stay.length})</h3><ul class="checkouts" data-stays>${plan.stay.map(one => item(one, csrf)).join("")}</ul>`) +
57
+ remove + `</div></details>`;
58
+ const options = CLEANUP_CHOICES.map(one => `<option value="${one.value}"${one.value === plan.cleanup ? " selected" : ""}>${e(one.label)}</option>`).join("");
59
+ return `<section class="storage">${note}` +
60
+ `<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${e(bytesWords(plan.totalBytes))}</h2><p class="meta">${e(facts)}</p>` +
61
+ `<p data-clean-bytes="${plan.freeBytes}">${e(frees)}</p>${preview}</div>` +
62
+ `<h2>Automatic cleanup</h2>` +
63
+ `<form method="post" action="/settings/storage" class="setting">${hidden}` +
64
+ `<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label>` +
65
+ `<p class="meta">Its branch always stays. Checkouts with changes, commits on no branch, in use, or waiting for review are kept.</p>` +
66
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button></form></section>`;
67
+ }
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import type { Store } from "./store.js";
2
+ /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
3
+ export declare const CHECKOUT_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS checkout_cleanup_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
4
+ export type CheckoutCleanup = "finished" | "2d" | "7d" | "never";
5
+ export declare const DEFAULT_CLEANUP: CheckoutCleanup;
6
+ export declare const CLEANUP_CHOICES: readonly {
7
+ value: CheckoutCleanup;
8
+ label: string;
9
+ days: number | null;
10
+ }[];
11
+ /** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
12
+ export declare function parseCleanup(text: string): CheckoutCleanup | undefined;
13
+ export declare function cleanupWords(cleanup: CheckoutCleanup): string;
14
+ /** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
15
+ export declare function diskBytes(paths: readonly string[]): Map<string, number>;
2
16
  export type StorageLine = {
3
17
  what: string;
4
18
  bytes: number;
@@ -7,7 +21,7 @@ export type StorageLine = {
7
21
  };
8
22
  /** Bytes a tree holds on disk (allocated blocks; links not followed). */
9
23
  export declare function treeBytes(path: string): number;
10
- export declare function storageReport(store: Store, databaseFile: string, now: Date, keepMs: number): {
24
+ export declare function storageReport(store: Store, databaseFile: string, checkoutNote?: string): {
11
25
  folder: string;
12
26
  total: number;
13
27
  lines: StorageLine[];
package/dist/storage.js CHANGED
@@ -2,11 +2,67 @@
2
2
  * Where Toolroll's disk goes (`toolroll storage`): the folder
3
3
  * beside the database, by kind, and what storage retention takes care of.
4
4
  * Build checkouts and staged releases are the big ones; the worker removes a
5
- * finished task's clean checkout two days after it was let go, and a deploy
6
- * keeps its last few releases.
5
+ * finished task's clean checkout as the checkout cleanup setting says
6
+ * (checkout-cleanup.ts), and a deploy keeps its last few releases.
7
7
  */
8
+ import { spawnSync } from "node:child_process";
8
9
  import { lstatSync, readdirSync } from "node:fs";
9
10
  import { basename, dirname, join } from "node:path";
11
+ /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
12
+ export const CHECKOUT_CLEANUP_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
14
+ id INTEGER PRIMARY KEY CHECK (id = 1),
15
+ cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),
16
+ updated_by TEXT NOT NULL,
17
+ updated_at TEXT NOT NULL
18
+ );
19
+ `;
20
+ export const DEFAULT_CLEANUP = "finished";
21
+ export const CLEANUP_CHOICES = [
22
+ { value: "finished", label: "When its task is complete or cancelled", days: 0 },
23
+ { value: "2d", label: "2 days after that", days: 2 },
24
+ { value: "7d", label: "A week after that", days: 7 },
25
+ { value: "never", label: "Never (only when you clean up)", days: null },
26
+ ];
27
+ /** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
28
+ export function parseCleanup(text) {
29
+ const value = text.trim().toLowerCase().replace(/\s+/g, " ");
30
+ if (["finished", "complete", "completed", "cancelled", "done", "0", "0d"].includes(value))
31
+ return "finished";
32
+ if (["2d", "2", "2 days", "2days"].includes(value))
33
+ return "2d";
34
+ if (["7d", "7", "7 days", "7days", "week", "a week", "1w"].includes(value))
35
+ return "7d";
36
+ if (["never", "off"].includes(value))
37
+ return "never";
38
+ return undefined;
39
+ }
40
+ export function cleanupWords(cleanup) {
41
+ return cleanup === "finished" ? "when its task is complete or cancelled"
42
+ : cleanup === "never" ? "never (only when you clean up)"
43
+ : `${cleanup === "2d" ? "2 days" : "a week"} after its task is complete or cancelled`;
44
+ }
45
+ /** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
46
+ export function diskBytes(paths) {
47
+ const sizes = new Map();
48
+ if (paths.length === 0)
49
+ return sizes;
50
+ if (process.platform !== "win32") {
51
+ for (let at = 0; at < paths.length; at += 200) {
52
+ const batch = paths.slice(at, at + 200);
53
+ const du = spawnSync("du", ["-sk", ...batch], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
54
+ for (const line of (du.stdout ?? "").split("\n")) {
55
+ const match = /^(\d+)\t(.+)$/.exec(line);
56
+ if (match !== null && batch.includes(match[2]))
57
+ sizes.set(match[2], Number(match[1]) * 1024);
58
+ }
59
+ }
60
+ }
61
+ for (const path of paths)
62
+ if (!sizes.has(path))
63
+ sizes.set(path, treeBytes(path));
64
+ return sizes;
65
+ }
10
66
  /** Bytes a tree holds on disk (allocated blocks; links not followed). */
11
67
  export function treeBytes(path) {
12
68
  let total = 0;
@@ -35,7 +91,7 @@ export function treeBytes(path) {
35
91
  }
36
92
  return total;
37
93
  }
38
- export function storageReport(store, databaseFile, now, keepMs) {
94
+ export function storageReport(store, databaseFile, checkoutNote) {
39
95
  const folder = dirname(databaseFile);
40
96
  const base = basename(databaseFile);
41
97
  let names = [];
@@ -45,7 +101,8 @@ export function storageReport(store, databaseFile, now, keepMs) {
45
101
  catch {
46
102
  names = [];
47
103
  }
48
- const sizes = new Map(names.map(name => [name, treeBytes(join(folder, name))]));
104
+ const measured = diskBytes(names.map(name => join(folder, name)));
105
+ const sizes = new Map(names.map(name => [name, measured.get(join(folder, name)) ?? 0]));
49
106
  const take = (match) => {
50
107
  let bytes = 0;
51
108
  for (const [name, size] of sizes)
@@ -56,7 +113,6 @@ export function storageReport(store, databaseFile, now, keepMs) {
56
113
  return bytes;
57
114
  };
58
115
  const checkouts = store.listWorktrees();
59
- const due = checkouts.filter(row => row.releasedAt !== null && row.runner === null && now.getTime() - Date.parse(row.releasedAt) >= keepMs).length;
60
116
  let staged = 0;
61
117
  try {
62
118
  staged = readdirSync(join(folder, "staged-upgrades")).length;
@@ -66,8 +122,7 @@ export function storageReport(store, databaseFile, now, keepMs) {
66
122
  }
67
123
  const lines = [
68
124
  { what: "Database", bytes: take(name => name.startsWith(base)) },
69
- { what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length,
70
- note: due === 0 ? "none due for removal" : `${due} let go over ${Math.round(keepMs / 86_400_000)} days ago: removed on the next pass if their task is finished and they're clean` },
125
+ { what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length, ...(checkoutNote === undefined ? {} : { note: checkoutNote }) },
71
126
  { what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "a deploy keeps the release it installed, the one before and the newest few" },
72
127
  { what: "Evidence", bytes: take(name => name === "evidence"), note: "the audit record of every run; kept" },
73
128
  { what: "Backups", bytes: take(name => name === "backups") },
package/dist/store.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { LimitReading, LimitWindow } from "./provider-limits.js";
16
16
  import { underCeiling, type OrgPolicy, type SavedPolicy } from "./policy.js";
17
17
  import { type Billing, type Budget, type BudgetAgent, type BudgetHold, type BudgetScope, type BudgetState, type SpendItem } from "./spend.js";
18
18
  import { type RetentionKind, type RetentionPeriods } from "./retention.js";
19
+ import { type CheckoutCleanup } from "./storage.js";
19
20
  import { type LedgerChainReport } from "./ledger-chain.js";
20
21
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
21
22
  export type BackupSettings = {
@@ -2645,11 +2646,6 @@ export declare class Store {
2645
2646
  getWorktree(path: string): WorktreeRow | null;
2646
2647
  /** Drop a row whose directory is gone; a lease over nothing only refuses work. */
2647
2648
  forgetWorktree(path: string): void;
2648
- /** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
2649
- * one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
2650
- * was marked complete (the completion names its family's root; its run names the version), or a release candidate
2651
- * done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
2652
- keptBranches(repo: string, now: Date, resultMs: number): string[];
2653
2649
  /**
2654
2650
  * Approve one repo's setup, revoking any predecessor in the same
2655
2651
  * transaction — exactly one live setup per repo, enforced by the partial
@@ -4337,7 +4333,7 @@ export declare class Store {
4337
4333
  * createConsoleTask. Set-once: an already-stamped row never changes. */
4338
4334
  stampFiledVia(refId: number, via: string): void;
4339
4335
  /**
4340
- * When this installation first finished a run successfully — the
4336
+ * When this installation first finished a run with a change — the
4341
4337
  * moment the first-run checklist retires, permanently. Derived from run
4342
4338
  * history the first time it is observed, then stamped as an append-only
4343
4339
  * installation fact so later pruning of those rows cannot resurrect the
@@ -5068,6 +5064,10 @@ export declare class Store {
5068
5064
  retentionPeriods(): RetentionPeriods;
5069
5065
  /** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
5070
5066
  setRetentionPeriod(kind: RetentionKind, days: number | null, by: string, now: Date): RetentionPeriods;
5067
+ /** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
5068
+ checkoutCleanup(): CheckoutCleanup;
5069
+ /** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
5070
+ setCheckoutCleanup(cleanup: CheckoutCleanup, by: string, now: Date): CheckoutCleanup;
5071
5071
  /** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
5072
5072
  recordProviderLimits(reading: LimitReading, now: Date): void;
5073
5073
  /** The latest reading of every provider's plan windows, shortest window first. */
package/dist/store.js CHANGED
@@ -67,9 +67,10 @@ import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyPa
67
67
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
68
68
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
69
69
  import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
70
+ import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
70
71
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
71
72
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
72
- import { BRANCH_PREFIXES, configBase, envValue, namedPath } from "./names.js";
73
+ import { configBase, envValue, namedPath } from "./names.js";
73
74
  // v60 fenced older readers before the chat action cards; v61 adds notification
74
75
  // provenance and Telegram destination receipts, and readers below v61 refuse it.
75
76
  // v62 adds the durable Telegram conversation queue and proposal-card tokens,
@@ -3695,6 +3696,7 @@ function initializeStore(db, file) {
3695
3696
  // The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
3696
3697
  addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
3697
3698
  db.exec(RETENTION_SCHEMA);
3699
+ db.exec(CHECKOUT_CLEANUP_SCHEMA);
3698
3700
  db.exec(BACKUP_SCHEMA);
3699
3701
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3700
3702
  db.exec(POLICY_SCHEMA);
@@ -9805,21 +9807,6 @@ export class Store {
9805
9807
  forgetWorktree(path) {
9806
9808
  this.db.prepare("DELETE FROM worktree WHERE path = ?").run(path);
9807
9809
  }
9808
- /** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
9809
- * one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
9810
- * was marked complete (the completion names its family's root; its run names the version), or a release candidate
9811
- * done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
9812
- keptBranches(repo, now, resultMs) {
9813
- const since = new Date(now.getTime() - resultMs).toISOString();
9814
- const tasks = `SELECT r.id AS ref, r.external_id AS id FROM task_ref r JOIN task t ON t.id = r.external_id LEFT JOIN task_scope s ON s.task_id = t.id
9815
- WHERE (r.repo = ? OR r.repo IS NULL) AND (t.state NOT IN ('done', 'cancelled') OR (t.state = 'done' AND (
9816
- (s.candidate IS NOT NULL AND t.updated_at >= ?) OR EXISTS (SELECT 1 FROM action_ledger l JOIN run ON run.id = l.run_id
9817
- WHERE run.task_ref = r.id AND l.action = 'assignment handoff checked' AND l.at >= ?))))`;
9818
- const own = this.db.prepare(tasks).all(repo, since, since).flatMap(row => BRANCH_PREFIXES.map(prefix => `${prefix}${String(row["id"])}`));
9819
- const used = this.db.prepare(`SELECT DISTINCT run.branch AS branch FROM run WHERE run.branch IS NOT NULL AND run.task_ref IN (SELECT ref FROM (${tasks}))`).all(repo, since, since)
9820
- .map(row => String(row["branch"]));
9821
- return [...new Set([...own, ...used])];
9822
- }
9823
9810
  // ---- worktree setup (M5.7) ---------------------------------------------
9824
9811
  /**
9825
9812
  * Approve one repo's setup, revoking any predecessor in the same
@@ -14391,7 +14378,7 @@ export class Store {
14391
14378
  this.db.prepare("UPDATE task_ref SET filed_via = ? WHERE id = ? AND filed_via IS NULL").run(via, refId);
14392
14379
  }
14393
14380
  /**
14394
- * When this installation first finished a run successfully — the
14381
+ * When this installation first finished a run with a change — the
14395
14382
  * moment the first-run checklist retires, permanently. Derived from run
14396
14383
  * history the first time it is observed, then stamped as an append-only
14397
14384
  * installation fact so later pruning of those rows cannot resurrect the
@@ -14402,7 +14389,7 @@ export class Store {
14402
14389
  if (fact !== null)
14403
14390
  return fact;
14404
14391
  const row = this.db
14405
- .prepare("SELECT finished_at FROM run WHERE outcome IN ('built','no-change') AND finished_at IS NOT NULL ORDER BY id LIMIT 1")
14392
+ .prepare("SELECT finished_at FROM run WHERE outcome = 'built' AND finished_at IS NOT NULL ORDER BY id LIMIT 1")
14406
14393
  .get();
14407
14394
  if (row === undefined)
14408
14395
  return null;
@@ -15938,8 +15925,9 @@ export class Store {
15938
15925
  this.recordRunProcessExits(id, result.now);
15939
15926
  this.settleRunStop(id, result.stopSettlement ?? "finished", result.now);
15940
15927
  // The first Ready result is an installation fact the moment it lands,
15941
- // not only when something later looks for it.
15942
- if (result.outcome === "built" || result.outcome === "no-change")
15928
+ // not only when something later looks for it. A run that changed
15929
+ // nothing is not a first result.
15930
+ if (result.outcome === "built")
15943
15931
  this.firstSuccessAt(result.now);
15944
15932
  // The park rate is *measured* — parked over concluded builder attempts —
15945
15933
  // and maintained where attempts conclude, because the attention budget's
@@ -16242,6 +16230,21 @@ export class Store {
16242
16230
  return this.retentionPeriods();
16243
16231
  });
16244
16232
  }
16233
+ /** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
16234
+ checkoutCleanup() {
16235
+ const row = this.db.prepare("SELECT cleanup FROM checkout_cleanup_setting WHERE id = 1").get();
16236
+ return row === undefined ? DEFAULT_CLEANUP : String(row["cleanup"]);
16237
+ }
16238
+ /** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
16239
+ setCheckoutCleanup(cleanup, by, now) {
16240
+ return this.transact(() => {
16241
+ const before = this.checkoutCleanup();
16242
+ this.db.prepare("INSERT INTO checkout_cleanup_setting (id, cleanup, updated_by, updated_at) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET cleanup = excluded.cleanup, updated_by = excluded.updated_by, updated_at = excluded.updated_at")
16243
+ .run(cleanup, by, now.toISOString());
16244
+ this.recordPolicy(by, null, "checkout cleanup changed", cleanupWords(before), cleanupWords(cleanup), now);
16245
+ return this.checkoutCleanup();
16246
+ });
16247
+ }
16245
16248
  /** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
16246
16249
  recordProviderLimits(reading, now) {
16247
16250
  this.transact(() => {