toolroll 0.9.61 → 0.9.62
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 +17 -0
- package/dist/cli.js +28 -11
- package/dist/landed-cleanup.d.ts +23 -10
- package/dist/landed-cleanup.js +188 -81
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/operate.js +67 -16
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/remote-main.d.ts +57 -0
- package/dist/remote-main.js +179 -0
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/settings.js +3 -2
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-process.d.ts +7 -0
- package/dist/storage-sweep-process.js +20 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +39 -3
- package/dist/store.js +96 -5
- package/dist/surface.js +4 -2
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/toolroll-update.js +3 -2
- package/dist/worktree.d.ts +54 -2
- package/dist/worktree.js +108 -13
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { gateWords } from "../approval-policy.js";
|
|
|
7
7
|
import { assignmentCardOf, assignmentStatusOf, assignmentSummaryHtml } from "../assignment-ui.js";
|
|
8
8
|
import {} from "../assignment.js";
|
|
9
9
|
import { holdOwnerWords } from "../board.js";
|
|
10
|
+
import { baseStandingWords } from "../task-base.js";
|
|
10
11
|
import {} from "../browser-workspace.js";
|
|
11
12
|
import { permissionPlainWords } from "../chat-decide.js";
|
|
12
13
|
import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
|
|
@@ -2108,6 +2109,8 @@ export function taskBodyParts(data) {
|
|
|
2108
2109
|
work.push({ label: "Status", parts: [data.assignment != null && assignmentOptions !== null ? assignmentCardOf(data.assignment, assignmentOptions).status.headline : status.label] });
|
|
2109
2110
|
if (data.repo !== null)
|
|
2110
2111
|
work.push({ label: "Project", parts: [projectName(data.repo)] });
|
|
2112
|
+
if (data.base != null)
|
|
2113
|
+
work.push({ label: "Base", parts: [baseStandingWords(data.base)] });
|
|
2111
2114
|
work.push({ label: "Agent", parts: [lastRunForAgent !== undefined ? `${lastRunForAgent.provider}${lastRunForAgent.model === null ? "" : ` · ${lastRunForAgent.model}`} on ${lastRunForAgent.runner}`
|
|
2112
2115
|
: data.route?.legacy != null ? `${data.route.legacy.provider} · ${data.route.legacy.model}` : "The project's default"] });
|
|
2113
2116
|
if (scope !== null)
|
package/dist/server/settings.js
CHANGED
|
@@ -104,6 +104,7 @@ import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as
|
|
|
104
104
|
import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
|
|
105
105
|
import { ssoSettingsHtml } from "../sso-ui.js";
|
|
106
106
|
import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
|
|
107
|
+
import { sweepSchedule } from "../storage-sweep-schedule.js";
|
|
107
108
|
import { storageHtml } from "../storage-ui.js";
|
|
108
109
|
import { landedSummary } from "../landed-cleanup.js";
|
|
109
110
|
import { bytesWords, parseCleanup } from "../storage.js";
|
|
@@ -302,7 +303,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
302
303
|
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
303
304
|
const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/project"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
|
|
304
305
|
const notice = { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") };
|
|
305
|
-
const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml(projectViewOf(chosen, who), notice));
|
|
306
|
+
const content = chosen === "" ? html `${notice.said ? html `<p role="status">${notice.said}</p>` : ""}<p>No projects yet.</p>` : (projectSettingsHtml({ ...projectViewOf(chosen, who), remoteUrl: store.projectRemote(chosen) }, notice));
|
|
306
307
|
return sendScreen(response, 200, screen("Project", html `<p><a href="/settings">Settings</a></p><h1>Project</h1>${selector}${content}`, { chrome: chromeFor(chosen || null, "settings") }));
|
|
307
308
|
}
|
|
308
309
|
// v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
|
|
@@ -398,7 +399,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
398
399
|
if (databaseFile === null)
|
|
399
400
|
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
400
401
|
const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
|
|
401
|
-
const sweep = { last: lastSweep(store), off: storageSweepOff() };
|
|
402
|
+
const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
|
|
402
403
|
return sendScreen(response, 200, screen("Storage", html `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${(storageHtml({ plan, csrf: who.session.csrf, sweep, landed: landedSummary(store) }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }))}`, { chrome: chromeFor(project, "settings") }));
|
|
403
404
|
}
|
|
404
405
|
// Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { taskBaseOf } from "../task-base.js";
|
|
1
2
|
import { assignmentPresentationOf, historicalAssessmentReason } from '../assignment-presentation.js';
|
|
2
3
|
import { html, joinHtml } from "../html.js";
|
|
3
4
|
import { assignmentStatusOf, assignmentWithEvidence } from '../assignment-ui.js';
|
|
@@ -482,6 +483,7 @@ export function createTaskViews(runtime) {
|
|
|
482
483
|
return { rows, decisions: decisionsFor(runtime.store, rows), now };
|
|
483
484
|
})(),
|
|
484
485
|
steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
|
|
486
|
+
base: taskBaseOf(runtime.store, taskId),
|
|
485
487
|
steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
|
|
486
488
|
// "publishes as" reads ONLY publicationGrantFor(repo) — the grant
|
|
487
489
|
// the publisher would act under — never listGrants(), which is
|
|
@@ -29,6 +29,13 @@ export type SweepSupervisor = {
|
|
|
29
29
|
};
|
|
30
30
|
export declare const SWEEP_TIMEOUT_MS: number;
|
|
31
31
|
export declare const SWEEP_KILL_GRACE_MS = 5000;
|
|
32
|
+
/** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
|
|
33
|
+
export declare const SWEEP_NICE = 10;
|
|
34
|
+
/** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
|
|
35
|
+
export declare function sweepLaunch(platform?: NodeJS.Platform, exists?: (path: string) => boolean): {
|
|
36
|
+
execPath: string;
|
|
37
|
+
prefix: string[];
|
|
38
|
+
};
|
|
32
39
|
/** Owns one child without lending its lifetime to a reconcile pass or its database connection. */
|
|
33
40
|
export declare function createSweepSupervisor(options?: {
|
|
34
41
|
timeoutMs?: number;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** The storage child's bounded IPC protocol. No paths or process arguments are written to logs. */
|
|
2
2
|
import { fork } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { setPriority } from "node:os";
|
|
3
5
|
import { resolve } from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -23,13 +25,28 @@ export function sweepResponse(value, request) {
|
|
|
23
25
|
}
|
|
24
26
|
export const SWEEP_TIMEOUT_MS = 20 * 60_000;
|
|
25
27
|
export const SWEEP_KILL_GRACE_MS = 5_000;
|
|
28
|
+
/** The sweep runs behind everything else: a positive nice and, on macOS, the background CPU and I/O policy. */
|
|
29
|
+
export const SWEEP_NICE = 10;
|
|
30
|
+
const TASKPOLICY = "/usr/sbin/taskpolicy";
|
|
31
|
+
/** How the child starts: taskpolicy execs Node in place, so the pid, its group and the IPC channel stay the same. */
|
|
32
|
+
export function sweepLaunch(platform = process.platform, exists = existsSync) {
|
|
33
|
+
return platform === "darwin" && exists(TASKPOLICY) ? { execPath: TASKPOLICY, prefix: ["-b", process.execPath] } : { execPath: process.execPath, prefix: [] };
|
|
34
|
+
}
|
|
26
35
|
/** Only source checkouts need tsx; installed builds use the sibling child and the same Node. */
|
|
27
36
|
function forkSweep() {
|
|
28
37
|
const source = import.meta.url.endsWith(".ts");
|
|
38
|
+
const launch = sweepLaunch();
|
|
29
39
|
const child = fork(fileURLToPath(new URL(source ? "./storage-sweep-child.ts" : "./storage-sweep-child.js", import.meta.url)), [], {
|
|
30
|
-
execArgv: source ? ["--import", "tsx"] : [], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
|
|
40
|
+
execPath: launch.execPath, execArgv: [...launch.prefix, ...(source ? ["--import", "tsx"] : [])], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
|
|
31
41
|
});
|
|
32
|
-
//
|
|
42
|
+
// Before the request is sent, so every rm/du the child starts inherits it.
|
|
43
|
+
if (child.pid !== undefined) {
|
|
44
|
+
try {
|
|
45
|
+
setPriority(child.pid, SWEEP_NICE);
|
|
46
|
+
}
|
|
47
|
+
catch { /* the default priority still works */ }
|
|
48
|
+
}
|
|
49
|
+
// The service never waits on the child to exit; stop() and the child's parent-loss guard end it.
|
|
33
50
|
child.unref();
|
|
34
51
|
child.channel?.unref();
|
|
35
52
|
return child;
|
|
@@ -165,7 +182,7 @@ export function createSweepSupervisor(options = {}) {
|
|
|
165
182
|
},
|
|
166
183
|
};
|
|
167
184
|
}
|
|
168
|
-
// All watch loops in this process share a flight. Other
|
|
185
|
+
// All watch loops in this process share a flight. Other services arbitrate the claim in SQLite.
|
|
169
186
|
const supervisors = new Map();
|
|
170
187
|
export function storageSweepSupervisor(databaseFile) {
|
|
171
188
|
const key = resolve(databaseFile);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type SweepDeps, type SweepRecord } from "./storage-sweep.js";
|
|
2
|
+
import { type SweepSupervisor } from "./storage-sweep-process.js";
|
|
3
|
+
import { type Store } from "./store.js";
|
|
4
|
+
/** How often the service looks again at a due sweep. */
|
|
5
|
+
export declare const SWEEP_CHECK_MS: number;
|
|
6
|
+
/** After this long without an automatic sweep, it runs even while work is running or waiting. */
|
|
7
|
+
export declare const SWEEP_FORCE_MS: number;
|
|
8
|
+
export type SweepBusy = "run" | "claim" | "ready";
|
|
9
|
+
/** What keeps a due sweep waiting now, or null when nothing is running or waiting. */
|
|
10
|
+
export declare function sweepBusy(store: Store, now: Date): SweepBusy | null;
|
|
11
|
+
/** Whether a long-lived Toolroll service holds a current lease. Rows from earlier services don't count. */
|
|
12
|
+
export declare function sweepServiceRunning(store: Store, now: Date): boolean;
|
|
13
|
+
export type SweepState = "off" | "sweeping" | "no-service" | "recent" | "retry" | "busy" | "forced" | "due";
|
|
14
|
+
export type SweepSchedule = {
|
|
15
|
+
state: SweepState;
|
|
16
|
+
/** The latest automatic sweep's recorded time, or null when there has been none. */
|
|
17
|
+
lastAt: string | null;
|
|
18
|
+
/** When the next sweep is due, or null when it is off or due now. */
|
|
19
|
+
nextAt: string | null;
|
|
20
|
+
busy: SweepBusy | null;
|
|
21
|
+
};
|
|
22
|
+
/** The sweep's schedule from what is recorded now; reads only. */
|
|
23
|
+
export declare function sweepSchedule(store: Store, now: Date, env?: NodeJS.ProcessEnv): SweepSchedule;
|
|
24
|
+
/** Why the sweep is waiting or when it is next due, after "Automatic sweep: ". `when` formats a time for the reader. */
|
|
25
|
+
export declare function sweepScheduleWords(schedule: SweepSchedule, when: (at: string) => string): string;
|
|
26
|
+
/**
|
|
27
|
+
* The service's claim and launch. A one-shot caller has no `owner` and never claims. A hung child never holds
|
|
28
|
+
* reconciliation or the caller's store open; the result is saved by the flight's own connection.
|
|
29
|
+
*/
|
|
30
|
+
export declare function idleStorageSweep(store: Store, deps: SweepDeps, now: () => Date, supervisor?: SweepSupervisor): SweepRecord | null;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the automatic storage sweep (storage-sweep.ts) starts, and what `toolroll storage` and Settings → Storage say
|
|
3
|
+
* about it. A sweep is due a day after the last one (an hour after one that failed somewhere). Once due, the
|
|
4
|
+
* long-lived Toolroll service looks at most every 15 minutes and starts it only while nothing is running or waiting:
|
|
5
|
+
* no open run, no live claim and no task a worker could claim now. Work held by an approval, a hold or a provider limit
|
|
6
|
+
* is not waiting to start, so it never defers the sweep. 48 hours after the last automatic sweep it runs anyway: its
|
|
7
|
+
* scans run in a low-priority child, and every removal asks again whether live work uses the folder.
|
|
8
|
+
*
|
|
9
|
+
* Only the service claims a sweep (it passes its owner): a one-shot `toolroll reconcile` exits before a child would
|
|
10
|
+
* finish, and a claim it made would be lost until the next day. The idle gates are asked again inside the transaction
|
|
11
|
+
* that claims the sweep, so work that started since the first look still defers it.
|
|
12
|
+
*/
|
|
13
|
+
import { diagnoseTaskDispatch, diagnosisIsDispatchable } from "./dispatch.js";
|
|
14
|
+
import { lastSweep, RETRY_MS, saveSweep, storageSweepOff, sweepFailure, SWEEP_CURSOR, SWEEP_EVERY_MS } from "./storage-sweep.js";
|
|
15
|
+
import { storageSweepSupervisor, SWEEP_KILL_GRACE_MS, SWEEP_TIMEOUT_MS } from "./storage-sweep-process.js";
|
|
16
|
+
import { openStoreNoMigrate } from "./store.js";
|
|
17
|
+
/** How often the service looks again at a due sweep. */
|
|
18
|
+
export const SWEEP_CHECK_MS = 15 * 60_000;
|
|
19
|
+
/** After this long without an automatic sweep, it runs even while work is running or waiting. */
|
|
20
|
+
export const SWEEP_FORCE_MS = 48 * 3_600_000;
|
|
21
|
+
/** What keeps a due sweep waiting now, or null when nothing is running or waiting. */
|
|
22
|
+
export function sweepBusy(store, now) {
|
|
23
|
+
const db = store.handle;
|
|
24
|
+
if (db.prepare("SELECT 1 FROM run WHERE finished_at IS NULL LIMIT 1").get() !== undefined)
|
|
25
|
+
return "run";
|
|
26
|
+
if (db.prepare("SELECT 1 FROM claim WHERE released_at IS NULL AND expires_at > ? LIMIT 1").get(now.toISOString()) !== undefined)
|
|
27
|
+
return "claim";
|
|
28
|
+
// Ready means a worker could claim it now: the same answer the task page gives, not merely queued.
|
|
29
|
+
if (store.listReady(now).some(ref => diagnosisIsDispatchable(diagnoseTaskDispatch(store, ref.externalId, now))))
|
|
30
|
+
return "ready";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** Whether a long-lived Toolroll service holds a current lease. Rows from earlier services don't count. */
|
|
34
|
+
export function sweepServiceRunning(store, now) {
|
|
35
|
+
return store.handle.prepare("SELECT 1 FROM watch_lease WHERE owner GLOB 'toolroll-service:*' AND expires_at > ? LIMIT 1").get(now.toISOString()) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
const dueAt = (store) => store.serviceCursor(SWEEP_CURSOR) + SWEEP_EVERY_MS;
|
|
38
|
+
/** Count from the last result, or the durable installation time before the first result. */
|
|
39
|
+
function forcedFrom(store, last) {
|
|
40
|
+
if (last !== null)
|
|
41
|
+
return Date.parse(last.at);
|
|
42
|
+
const installed = store.installationFact("installed-at");
|
|
43
|
+
return installed === null ? null : Date.parse(installed);
|
|
44
|
+
}
|
|
45
|
+
/** The sweep's schedule from what is recorded now; reads only. */
|
|
46
|
+
export function sweepSchedule(store, now, env = process.env) {
|
|
47
|
+
const last = lastSweep(store, "automatic");
|
|
48
|
+
const lastAt = last?.at ?? null;
|
|
49
|
+
if (storageSweepOff(env))
|
|
50
|
+
return { state: "off", lastAt, nextAt: null, busy: null };
|
|
51
|
+
const cursor = store.serviceCursor(SWEEP_CURSOR);
|
|
52
|
+
// Results record completion time, which may be later than the claim. An older result cannot finish a new claim.
|
|
53
|
+
if (cursor !== 0 && now.getTime() - cursor < SWEEP_TIMEOUT_MS + SWEEP_KILL_GRACE_MS && now.getTime() >= cursor && (lastAt === null || Date.parse(lastAt) < cursor)) {
|
|
54
|
+
return { state: "sweeping", lastAt, nextAt: null, busy: null };
|
|
55
|
+
}
|
|
56
|
+
const due = dueAt(store);
|
|
57
|
+
const nextAt = cursor === 0 || due <= now.getTime() ? null : new Date(due).toISOString();
|
|
58
|
+
if (!sweepServiceRunning(store, now))
|
|
59
|
+
return { state: "no-service", lastAt, nextAt, busy: null };
|
|
60
|
+
if (nextAt !== null) {
|
|
61
|
+
// Completion can follow the claim: elapsed sweep time alone does not mean a retry.
|
|
62
|
+
const retry = last !== null && Date.parse(last.at) >= cursor && last.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined);
|
|
63
|
+
return { state: retry ? "retry" : "recent", lastAt, nextAt, busy: null };
|
|
64
|
+
}
|
|
65
|
+
const from = forcedFrom(store, last);
|
|
66
|
+
if (from !== null && now.getTime() - from >= SWEEP_FORCE_MS)
|
|
67
|
+
return { state: "forced", lastAt, nextAt: null, busy: null };
|
|
68
|
+
const busy = sweepBusy(store, now);
|
|
69
|
+
return { state: busy === null ? "due" : "busy", lastAt, nextAt: null, busy };
|
|
70
|
+
}
|
|
71
|
+
const BUSY_WORDS = { run: "work is running", claim: "a worker is starting a task", ready: "a task is ready to start" };
|
|
72
|
+
/** Why the sweep is waiting or when it is next due, after "Automatic sweep: ". `when` formats a time for the reader. */
|
|
73
|
+
export function sweepScheduleWords(schedule, when) {
|
|
74
|
+
switch (schedule.state) {
|
|
75
|
+
case "off": return "off.";
|
|
76
|
+
case "sweeping": return "sweeping now.";
|
|
77
|
+
case "no-service": return "waiting: no Toolroll service running.";
|
|
78
|
+
case "recent": return `swept recently, next due ${when(schedule.nextAt)}.`;
|
|
79
|
+
case "retry": return `retry due ${when(schedule.nextAt)}.`;
|
|
80
|
+
case "busy": return `due, waiting: ${BUSY_WORDS[schedule.busy]}.`;
|
|
81
|
+
case "forced": return "over 48 hours since the last sweep; it runs at the next check even if work is running.";
|
|
82
|
+
case "due": return "due; it starts within 15 minutes while nothing is running.";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The last look per flight, so a due sweep is looked at no more than every 15 minutes.
|
|
86
|
+
const looked = new WeakMap();
|
|
87
|
+
/**
|
|
88
|
+
* The service's claim and launch. A one-shot caller has no `owner` and never claims. A hung child never holds
|
|
89
|
+
* reconciliation or the caller's store open; the result is saved by the flight's own connection.
|
|
90
|
+
*/
|
|
91
|
+
export function idleStorageSweep(store, deps, now, supervisor = storageSweepSupervisor(deps.databaseFile)) {
|
|
92
|
+
if (deps.owner === undefined || storageSweepOff() || supervisor.running)
|
|
93
|
+
return null;
|
|
94
|
+
const at = now();
|
|
95
|
+
// Older installations have no creation fact. Anchor once on their first service pass; never backdate a claim.
|
|
96
|
+
if (store.installationFact("installed-at") === null)
|
|
97
|
+
store.recordInstallationFact("installed-at", at.toISOString(), at);
|
|
98
|
+
if (at.getTime() < dueAt(store))
|
|
99
|
+
return null;
|
|
100
|
+
const seen = looked.get(supervisor);
|
|
101
|
+
if (seen !== undefined && at.getTime() - seen < SWEEP_CHECK_MS && at.getTime() >= seen)
|
|
102
|
+
return null;
|
|
103
|
+
looked.set(supervisor, at.getTime());
|
|
104
|
+
const ready = () => {
|
|
105
|
+
if (at.getTime() < dueAt(store))
|
|
106
|
+
return false;
|
|
107
|
+
const from = forcedFrom(store, lastSweep(store, "automatic"));
|
|
108
|
+
return (from !== null && at.getTime() - from >= SWEEP_FORCE_MS) || sweepBusy(store, at) === null;
|
|
109
|
+
};
|
|
110
|
+
if (!ready())
|
|
111
|
+
return null;
|
|
112
|
+
// This handle belongs to the flight, not to the pass that has already returned by the time it finishes.
|
|
113
|
+
const connection = openStoreNoMigrate(deps.databaseFile);
|
|
114
|
+
if (!connection.ok)
|
|
115
|
+
throw new Error("Sweep result store unavailable");
|
|
116
|
+
const results = connection.store;
|
|
117
|
+
let completed = null;
|
|
118
|
+
let owned = false;
|
|
119
|
+
try {
|
|
120
|
+
// Asked again where the cursor moves: work that started since the first look still defers the sweep.
|
|
121
|
+
const mine = store.transact(() => {
|
|
122
|
+
if (!ready())
|
|
123
|
+
return false;
|
|
124
|
+
store.setServiceCursor(SWEEP_CURSOR, at.getTime(), at);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
if (!mine)
|
|
128
|
+
return null;
|
|
129
|
+
const request = { version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor: deps.actor ?? "worker", at: at.toISOString() };
|
|
130
|
+
owned = supervisor.start(request, result => {
|
|
131
|
+
const record = result.ok ? result.record : sweepFailure(request.at, request.actor, deps.pool !== null, result.reason);
|
|
132
|
+
try {
|
|
133
|
+
results.transact(() => {
|
|
134
|
+
if (!results.schemaCurrent())
|
|
135
|
+
throw new Error("Sweep result store changed");
|
|
136
|
+
saveSweep(results, record);
|
|
137
|
+
// Only this claim can reschedule itself. A later claim must never be overwritten by a late callback.
|
|
138
|
+
if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined) && results.serviceCursor(SWEEP_CURSOR) === at.getTime()) {
|
|
139
|
+
results.setServiceCursor(SWEEP_CURSOR, Math.max(1, now().getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
completed = record;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// No exception/path from a process or database is safe to copy into a log.
|
|
146
|
+
console.error("Could not save the storage sweep result.");
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
results.close();
|
|
150
|
+
}
|
|
151
|
+
}, deps.owner);
|
|
152
|
+
if (!owned) {
|
|
153
|
+
store.transact(() => {
|
|
154
|
+
if (store.serviceCursor(SWEEP_CURSOR) === at.getTime())
|
|
155
|
+
store.setServiceCursor(SWEEP_CURSOR, Math.max(1, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return completed;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if (!owned)
|
|
162
|
+
results.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
package/dist/storage-sweep.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Located } from "./orphan-sweep.js";
|
|
2
2
|
import { type ProcessRow } from "./run-closeout.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
4
|
import { type WorktreePool } from "./worktree.js";
|
|
5
|
-
import { type
|
|
5
|
+
import { type SweepCompletion, type SweepExecutor } from "./storage-sweep-process.js";
|
|
6
|
+
export declare const SWEEP_CURSOR = "storage-sweep";
|
|
6
7
|
export declare const SWEEP_EVERY_MS = 86400000;
|
|
7
8
|
/** A sweep that failed somewhere, or left work for later, runs again this soon. */
|
|
8
9
|
export declare const RETRY_MS: number;
|
|
@@ -36,9 +37,9 @@ export type SweepDeps = {
|
|
|
36
37
|
graceMs?: number;
|
|
37
38
|
};
|
|
38
39
|
export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
|
|
39
|
-
export declare function
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
export declare function sweepFailure(at: string, actor: string, withPool: boolean, reason: Exclude<SweepCompletion, {
|
|
41
|
+
ok: true;
|
|
42
|
+
}>["reason"]): SweepRecord;
|
|
42
43
|
/**
|
|
43
44
|
* What live work uses now. Live work: an open run, a current (unexpired, unreleased) lease, or a checkout leased, held
|
|
44
45
|
* by a process or whose task has either. It uses its checkouts, the shared install each links, and every folder its
|
package/dist/storage-sweep.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
|
|
2
|
+
* The storage sweep: the plane cleans up after itself without anyone running a command. About once a day, when nothing
|
|
3
|
+
* is running or waiting (storage-sweep-schedule.ts: one service takes it, the cursor moves in the same transaction),
|
|
4
|
+
* it removes test temp folders nothing touched for a day (test-temp.ts), stops orphans Toolroll left running (orphan-sweep.ts), removes finished checkouts the cleanup
|
|
5
5
|
* setting lets go (checkout-cleanup.ts), keeps the current, previous and newest staged runtimes (staged-runtimes.ts)
|
|
6
6
|
* and removes shared dependency installs no checkout has used for a week (shared-deps.ts).
|
|
7
7
|
*
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*
|
|
18
18
|
* What each sweep did is saved (the latest few) and in the ledger: `toolroll storage` and Settings → Storage show it.
|
|
19
19
|
* A clean-up by hand (`storage clean --yes`) is saved the same way. A part that failed, or had more than one pass
|
|
20
|
-
* takes, brings the next sweep forward to an hour later
|
|
21
|
-
* (the unit tests do, so a test never sweeps the machine
|
|
20
|
+
* takes, brings the next sweep forward to an hour later; it still waits for nothing to be running.
|
|
21
|
+
* `TOOLROLL_STORAGE_SWEEP=off` turns the automatic sweep off (the unit tests do, so a test never sweeps the machine
|
|
22
|
+
* it runs on).
|
|
22
23
|
*/
|
|
23
24
|
import { realpathSync } from "node:fs";
|
|
24
25
|
import { hostname } from "node:os";
|
|
@@ -29,11 +30,10 @@ import { processCensus } from "./run-closeout.js";
|
|
|
29
30
|
import { linkedKey, pruneUnusedShared, sharedDepsRoot, sharedUse, SHARED_UNUSED_MS } from "./shared-deps.js";
|
|
30
31
|
import { pruneStagedRuntimes, runtimesInUse, runtimeScriptPaths, stagePlan } from "./staged-runtimes.js";
|
|
31
32
|
import { bytesWords, diskBytes } from "./storage.js";
|
|
32
|
-
import { openStoreNoMigrate } from "./store.js";
|
|
33
33
|
import { removeStaleTestTemp } from "./test-temp.js";
|
|
34
34
|
import { worktreeProcessOccupancy } from "./worktree.js";
|
|
35
|
-
import { runSweepChild
|
|
36
|
-
const
|
|
35
|
+
import { runSweepChild } from "./storage-sweep-process.js";
|
|
36
|
+
export const SWEEP_CURSOR = "storage-sweep";
|
|
37
37
|
export const SWEEP_EVERY_MS = 86_400_000;
|
|
38
38
|
/** A sweep that failed somewhere, or left work for later, runs again this soon. */
|
|
39
39
|
export const RETRY_MS = 60 * 60_000;
|
|
@@ -46,67 +46,7 @@ const ITEMS_MAX = 20;
|
|
|
46
46
|
export function storageSweepOff(env = process.env) {
|
|
47
47
|
return (env["TOOLROLL_STORAGE_SWEEP"] ?? "").trim().toLowerCase() === "off";
|
|
48
48
|
}
|
|
49
|
-
export function
|
|
50
|
-
return now.getTime() - store.serviceCursor(CURSOR) >= SWEEP_EVERY_MS;
|
|
51
|
-
}
|
|
52
|
-
/** Claim and launch only. A hung child never holds reconciliation or the caller's store open. */
|
|
53
|
-
export function dailyStorageSweep(store, deps, now, supervisor = storageSweepSupervisor(deps.databaseFile)) {
|
|
54
|
-
if (storageSweepOff() || supervisor.running || !storageSweepDue(store, now()))
|
|
55
|
-
return null;
|
|
56
|
-
// This handle belongs to the flight, not to the command that has already returned by the time it finishes.
|
|
57
|
-
const connection = openStoreNoMigrate(deps.databaseFile);
|
|
58
|
-
if (!connection.ok)
|
|
59
|
-
throw new Error("Sweep result store unavailable");
|
|
60
|
-
const results = connection.store;
|
|
61
|
-
const at = now();
|
|
62
|
-
let completed = null;
|
|
63
|
-
let owned = false;
|
|
64
|
-
try {
|
|
65
|
-
const mine = store.transact(() => {
|
|
66
|
-
if (!storageSweepDue(store, at))
|
|
67
|
-
return false;
|
|
68
|
-
store.setServiceCursor(CURSOR, at.getTime(), at);
|
|
69
|
-
return true;
|
|
70
|
-
});
|
|
71
|
-
if (!mine)
|
|
72
|
-
return null;
|
|
73
|
-
const request = { version: 1, databaseFile: deps.databaseFile, poolRoot: deps.pool?.root ?? null, tempRoots: [...deps.tempRoots], actor: deps.actor ?? "worker", at: at.toISOString() };
|
|
74
|
-
owned = supervisor.start(request, result => {
|
|
75
|
-
const record = result.ok ? result.record : sweepFailure(request.at, request.actor, deps.pool !== null, result.reason);
|
|
76
|
-
try {
|
|
77
|
-
results.transact(() => {
|
|
78
|
-
if (!results.schemaCurrent())
|
|
79
|
-
throw new Error("Sweep result store changed");
|
|
80
|
-
saveSweep(results, record);
|
|
81
|
-
// Only this claim can reschedule itself. A later claim must never be overwritten by a late callback.
|
|
82
|
-
if (record.parts.some(one => one.failed > 0 || one.more === true || one.problem !== undefined) && results.serviceCursor(CURSOR) === at.getTime()) {
|
|
83
|
-
results.setServiceCursor(CURSOR, Math.max(0, now().getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
completed = record;
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
// No exception/path from a process or database is safe to copy into a log.
|
|
90
|
-
console.error("Could not save the storage sweep result.");
|
|
91
|
-
}
|
|
92
|
-
finally {
|
|
93
|
-
results.close();
|
|
94
|
-
}
|
|
95
|
-
}, deps.owner);
|
|
96
|
-
if (!owned) {
|
|
97
|
-
store.transact(() => {
|
|
98
|
-
if (store.serviceCursor(CURSOR) === at.getTime())
|
|
99
|
-
store.setServiceCursor(CURSOR, Math.max(0, at.getTime() - SWEEP_EVERY_MS + RETRY_MS), now());
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
return completed;
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
if (!owned)
|
|
106
|
-
results.close();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function sweepFailure(at, actor, withPool, reason) {
|
|
49
|
+
export function sweepFailure(at, actor, withPool, reason) {
|
|
110
50
|
const problem = reason === "timeout" ? "The sweep process exceeded its time limit; some cleanup may have completed. Cleanup will retry later."
|
|
111
51
|
: reason === "stopped" ? "The sweep process was stopped; some cleanup may have completed. Cleanup will retry later."
|
|
112
52
|
: "The sweep process did not return a valid result; some cleanup may have completed.";
|
|
@@ -339,7 +279,7 @@ export function lastSweep(store, source) {
|
|
|
339
279
|
export function nextSweepAt(store) {
|
|
340
280
|
if (storageSweepOff())
|
|
341
281
|
return null;
|
|
342
|
-
const last = store.serviceCursor(
|
|
282
|
+
const last = store.serviceCursor(SWEEP_CURSOR);
|
|
343
283
|
return last === 0 ? null : new Date(last + SWEEP_EVERY_MS).toISOString();
|
|
344
284
|
}
|
|
345
285
|
const NOUNS = {
|
package/dist/storage-ui.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Html } from "./html.js";
|
|
2
2
|
import { type CheckoutPlan } from "./checkout-cleanup.js";
|
|
3
3
|
import { type SweepRecord } from "./storage-sweep.js";
|
|
4
|
+
import { type SweepSchedule } from "./storage-sweep-schedule.js";
|
|
4
5
|
import type { LandedSummary } from "./landed-cleanup.js";
|
|
5
6
|
export declare const STORAGE_CSS: string;
|
|
6
7
|
export type StorageView = {
|
|
@@ -9,6 +10,7 @@ export type StorageView = {
|
|
|
9
10
|
sweep?: {
|
|
10
11
|
last: SweepRecord | null;
|
|
11
12
|
off: boolean;
|
|
13
|
+
schedule?: SweepSchedule;
|
|
12
14
|
};
|
|
13
15
|
landed?: LandedSummary;
|
|
14
16
|
};
|
package/dist/storage-ui.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
|
|
3
3
|
* preview (what goes, what stays and why, with paths) sits behind the password, what the last storage sweep removed
|
|
4
|
-
* and when (its paths in a disclosure), what landed work's teardown did and still waits on (in a disclosure, with the
|
|
4
|
+
* and when (its paths in a disclosure) and why the next one waits, what landed work's teardown did and still waits on (in a disclosure, with the
|
|
5
5
|
* one-time command for older work), and when a finished task's clean checkout goes by itself. An instance operator's page.
|
|
6
6
|
*/
|
|
7
7
|
import { homedir } from "node:os";
|
|
@@ -10,6 +10,7 @@ import { html, postForm } from "./html.js";
|
|
|
10
10
|
import { previewDigest, whyWords } from "./checkout-cleanup.js";
|
|
11
11
|
import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
|
|
12
12
|
import { sweepDetails, sweepWords } from "./storage-sweep.js";
|
|
13
|
+
import { sweepScheduleWords } from "./storage-sweep-schedule.js";
|
|
13
14
|
const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
14
15
|
export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
|
|
15
16
|
`.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
|
|
@@ -45,15 +46,17 @@ function whenWords(at) {
|
|
|
45
46
|
const date = new Date(at);
|
|
46
47
|
return Number.isNaN(date.getTime()) ? at : date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
47
48
|
}
|
|
48
|
-
/**
|
|
49
|
+
/** When the last sweep ran and what it did, then why the next one waits or when it is due; paths and problems behind Details. */
|
|
49
50
|
function sweepHtml(sweep) {
|
|
50
51
|
const last = sweep.last;
|
|
52
|
+
const schedule = sweep.schedule === undefined ? (sweep.off ? html `<p class="meta" data-sweep-state="off">Automatic sweep: off.</p>` : "")
|
|
53
|
+
: html `<p class="meta" data-sweep-state="${sweep.schedule.state}">Automatic sweep: ${sweepScheduleWords(sweep.schedule, whenWords)}</p>`;
|
|
51
54
|
if (last === null)
|
|
52
|
-
return html `<
|
|
55
|
+
return html `<div class="sweep" data-last-sweep=""><p>Not swept yet.</p>${schedule}</div>`;
|
|
53
56
|
const who = last.source === "manual" ? `Cleaned up by ${last.actor}` : "Swept";
|
|
54
57
|
const details = sweepDetails(last.parts);
|
|
55
58
|
const problem = last.parts.some(one => one.failed > 0 || one.problem !== undefined);
|
|
56
|
-
return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
|
|
59
|
+
return html `<div class="sweep" data-last-sweep="${last.at}"><p${problem ? html ` class="problem"` : ""}>${who} <time datetime="${last.at}">${whenWords(last.at)}</time>. ${sweepWords(last.parts)}</p>${schedule}${details.length === 0 ? "" : html `<details class="sweep-details"><summary>Details</summary><ul>${details.map(line => html `<li>${homeAsTilde(line)}</li>`)}</ul></details>`}</div>`;
|
|
57
60
|
}
|
|
58
61
|
/** Landed work, on demand: what its teardown did, what it waits on and why, and the one-time command for older work. */
|
|
59
62
|
function landedHtml(landed) {
|
package/dist/store.d.ts
CHANGED
|
@@ -78,6 +78,11 @@ export declare const FLOW_SEND_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_send
|
|
|
78
78
|
* vocabulary and when — never text, tool names or arguments. Display state, not evidence;
|
|
79
79
|
* a finished run's row goes with the live log's own retention (sweepRunActivity). */
|
|
80
80
|
export declare const RUN_ACTIVITY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_activity (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK (kind IN ('session','message','edit','command','read','search','lookup','plan','delegate','tool')),\n at TEXT NOT NULL\n);\n";
|
|
81
|
+
/** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
|
|
82
|
+
* had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
|
|
83
|
+
* moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
|
|
84
|
+
* the older main it left. */
|
|
85
|
+
export declare const BRANCH_BASE_MOVE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS branch_base_move (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n branch TEXT NOT NULL,\n from_rev TEXT NOT NULL,\n to_rev TEXT NOT NULL,\n after_run INTEGER NOT NULL,\n moved_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);\n";
|
|
81
86
|
/** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
|
|
82
87
|
* stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
|
|
83
88
|
* request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
|
|
@@ -161,7 +166,7 @@ export type SealedLedgerEntry = LedgerEntry & {
|
|
|
161
166
|
hash: string;
|
|
162
167
|
} | null;
|
|
163
168
|
};
|
|
164
|
-
export declare const SCHEMA_VERSION =
|
|
169
|
+
export declare const SCHEMA_VERSION = 122;
|
|
165
170
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
166
171
|
export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
|
|
167
172
|
/**
|
|
@@ -220,7 +225,9 @@ export declare const V115_DROPPED_TABLES: readonly string[];
|
|
|
220
225
|
* (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
|
|
221
226
|
* the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
|
|
222
227
|
* three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
|
|
223
|
-
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
|
|
228
|
+
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
|
|
229
|
+
* table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
|
|
230
|
+
* same way.
|
|
224
231
|
*/
|
|
225
232
|
export declare const UPDATE_SAFE_MIGRATIONS: readonly number[];
|
|
226
233
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
@@ -250,6 +257,12 @@ export declare const V121_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
|
|
|
250
257
|
definition: string;
|
|
251
258
|
saved: string;
|
|
252
259
|
}>>>>;
|
|
260
|
+
/** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
|
|
261
|
+
* recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
|
|
262
|
+
export declare const V122_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
|
|
263
|
+
definition: string;
|
|
264
|
+
saved: string;
|
|
265
|
+
}>>>>;
|
|
253
266
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
254
267
|
export declare function updateSafeSchema(version: number | null): boolean;
|
|
255
268
|
export type OAuthClientRow = {
|
|
@@ -4350,6 +4363,20 @@ export declare class Store {
|
|
|
4350
4363
|
* legacy first-attempt behavior, unchanged byte-for-byte.
|
|
4351
4364
|
*/
|
|
4352
4365
|
firstBuilderBase(taskRef: number, branch: string): string | null;
|
|
4366
|
+
/** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
|
|
4367
|
+
* it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
|
|
4368
|
+
* current main until the next attempt records its base. */
|
|
4369
|
+
recordedBranchBase(taskRef: number, branch: string): string | null;
|
|
4370
|
+
/** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
|
|
4371
|
+
* attempt's own base (still the old one) as the branch's origin, which is exactly right. */
|
|
4372
|
+
recordBranchBaseMove(taskRef: number, branch: string, from: string, to: string, now: Date): void;
|
|
4373
|
+
/** The newest move of a task branch to current main, or null. */
|
|
4374
|
+
latestBranchBaseMove(taskRef: number, branch: string): {
|
|
4375
|
+
from: string;
|
|
4376
|
+
to: string;
|
|
4377
|
+
afterRun: number;
|
|
4378
|
+
movedAt: string;
|
|
4379
|
+
} | null;
|
|
4353
4380
|
finishRun(id: number, result: {
|
|
4354
4381
|
outcome: "built" | "failed" | "refused" | "parked" | "no-change" | "interrupted";
|
|
4355
4382
|
reason?: string;
|
|
@@ -6004,7 +6031,15 @@ export declare class Store {
|
|
|
6004
6031
|
leadStepsOf(leadTurn: number): ChatTurn[];
|
|
6005
6032
|
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
6006
6033
|
* A project added for the first time starts on Quick checks. */
|
|
6007
|
-
upsertProject(path: string, name: string, now: Date
|
|
6034
|
+
upsertProject(path: string, name: string, now: Date, admission?: {
|
|
6035
|
+
remoteUrl: string | null;
|
|
6036
|
+
}): void;
|
|
6037
|
+
/** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
|
|
6038
|
+
projectAdmissionKnown(path: string): boolean;
|
|
6039
|
+
/** Full structured URL audit, without the generic ledger's display-text truncation. */
|
|
6040
|
+
recordProjectRemote(actor: string, repo: string, previous: string | null, remoteUrl: string, now: Date): void;
|
|
6041
|
+
/** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
|
|
6042
|
+
projectRemote(path: string): string | null;
|
|
6008
6043
|
/** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
|
|
6009
6044
|
forgetProject(path: string): void;
|
|
6010
6045
|
/** Most recently opened first — the opener page's order. */
|
|
@@ -6013,6 +6048,7 @@ export declare class Store {
|
|
|
6013
6048
|
name: string;
|
|
6014
6049
|
addedAt: string;
|
|
6015
6050
|
lastOpenedAt: string;
|
|
6051
|
+
remoteUrl: string | null;
|
|
6016
6052
|
}[];
|
|
6017
6053
|
/**
|
|
6018
6054
|
* Tasks whose scope waits on a person's yes: proposed, not approved (or
|