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/CHANGELOG.md +40 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +45 -18
- package/dist/agent-onboard.js +146 -57
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +14 -13
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +8 -2
- package/dist/first-run.js +12 -8
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +2 -4
- package/dist/operate.js +167 -30
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +41 -0
- package/dist/serve.js +103 -28
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +6 -6
- package/dist/store.js +23 -20
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +7 -6
- package/dist/toolroll-update.d.ts +69 -15
- package/dist/toolroll-update.js +572 -110
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
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:
|
|
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" ?
|
|
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
|
-
|
|
3882
|
+
function firstRunStepsNow(now) {
|
|
3869
3883
|
if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
|
|
3870
3884
|
return null;
|
|
3871
|
-
return firstRunSteps({ agentSignedIn:
|
|
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
|
-
/**
|
|
3880
|
-
*
|
|
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()
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
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
|
-
|
|
3892
|
-
const steps =
|
|
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 ? [] :
|
|
3900
|
-
sandbox: steps.find(one => one.key === "agent"
|
|
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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(() => {
|