toolroll 0.9.61 → 0.9.63
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 +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- 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/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -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 +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/server/flows.js
CHANGED
|
@@ -21,6 +21,7 @@ import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, pl
|
|
|
21
21
|
import { addFlowTriggerTo, checkFlowTriggerNow, pressFlowButton, removeFlowTrigger, removeLinearKey, setFlowTriggerOn, renewFlowHook, saveHooksBase, saveLinearKey, saveLinearSigningSecret, shareFlowButton, stopSharingFlowButton } from "../flow-triggers.js";
|
|
22
22
|
import { FLOW_IMPORT_SCRIPT, flowFallbackHtml, flowImportHtml, flowsListHtml, flowView } from "../flows-ui.js";
|
|
23
23
|
import { FLOW_TEMPLATES, FlowContractError, validateFlowDefinition, withZoneNames } from "../flows.js";
|
|
24
|
+
import { prepareUnattendedCandidates, parseUnattendedAsk, revokeUnattended, signUnattended, untilWords } from "../flow-unattended.js";
|
|
24
25
|
import { keyStatus } from "../keys.js";
|
|
25
26
|
import { kitPageHtml, kitsGalleryHtml } from "../kits-ui.js";
|
|
26
27
|
import { addKitGithubTrigger, addKitSample, kitOf, setUpKit } from "../kits.js";
|
|
@@ -148,7 +149,7 @@ export function createFlowsHandlers(runtime) {
|
|
|
148
149
|
return refuse(response, who, 404, "No such flow in your projects.", "/flows");
|
|
149
150
|
const selected = Number(url.searchParams.get("card")), start = Number(url.searchParams.get("start"));
|
|
150
151
|
const view = flowView(store, flow, { name: who.name, approver: who.via === "cookie" && who.role === "approver" }, Number.isSafeInteger(selected) && selected > 0 ? selected : null, { dir: options.configDir ?? null, repos: consoleProjects(), startTrigger: Number.isSafeInteger(start) && start > 0 ? start : null,
|
|
151
|
-
sortReady: keyStatus("openrouter", providerHome).set, toolHome });
|
|
152
|
+
sortReady: keyStatus("openrouter", providerHome).set, toolHome, now: clock() });
|
|
152
153
|
if (url.searchParams.get("format") === "json")
|
|
153
154
|
return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("flowView", view)));
|
|
154
155
|
return sendScreen(response, 200, screen(flow.name, html `<p><a href="/flows">Flows</a></p><h1>${flow.name}</h1>${flowFallbackHtml(view)}`, { chrome: chromeFor(flow.repo, "flows"), workspace: { view } }));
|
|
@@ -546,7 +547,7 @@ export function createFlowsHandlers(runtime) {
|
|
|
546
547
|
}
|
|
547
548
|
const definition = flowDefinitionOf(flow);
|
|
548
549
|
const dir = options.configDir ?? null;
|
|
549
|
-
const viewNow = () => flowView(store, store.getFlow(flow.id) ?? flow, { name: who.name, approver: true }, null, { dir, repos: projects, sortReady: keyStatus("openrouter", providerHome).set, toolHome });
|
|
550
|
+
const viewNow = () => flowView(store, store.getFlow(flow.id) ?? flow, { name: who.name, approver: true }, null, { dir, repos: projects, sortReady: keyStatus("openrouter", providerHome).set, toolHome, now });
|
|
550
551
|
const settle = (said, extra = {}) => {
|
|
551
552
|
// Move what can move right away (a message, a decision's next zone); workers file and run the tasks.
|
|
552
553
|
try {
|
|
@@ -572,6 +573,8 @@ export function createFlowsHandlers(runtime) {
|
|
|
572
573
|
return answer(400, { ok: false, said: "That trigger couldn't be read." });
|
|
573
574
|
}
|
|
574
575
|
const made = addFlowTriggerTo(store, flow, raw, who.name, now, dir);
|
|
576
|
+
if (made.ok)
|
|
577
|
+
await prepareUnattendedCandidates(store, flow.id);
|
|
575
578
|
return made.ok ? settle(made.said, made.reveal === null ? {} : { reveal: made.reveal }) : answer(400, { ok: false, said: made.message });
|
|
576
579
|
}
|
|
577
580
|
if (action === "scripts") {
|
|
@@ -579,8 +582,10 @@ export function createFlowsHandlers(runtime) {
|
|
|
579
582
|
if (body.get("approve") === "yes")
|
|
580
583
|
return store.approveFlowScript(flow.repo, body.get("name") ?? "") ? settle("Approved. Zones that run it go on.") : answer(404, { ok: false, said: "That script isn't waiting for approval." });
|
|
581
584
|
if (body.get("remove") === "yes")
|
|
582
|
-
return store.removeFlowScript(flow.repo, body.get("name") ?? "") ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
|
|
585
|
+
return store.removeFlowScript(flow.repo, body.get("name") ?? "", who.name, now) ? settle("Script removed. Zones that ran it wait until it's back.") : answer(404, { ok: false, said: "There's no script by that name." });
|
|
583
586
|
const saved = saveScript(store, flow.repo, { name: body.get("name"), about: body.get("about"), body: body.get("body"), timeoutMinutes: body.get("timeoutMinutes"), language: body.get("language"), file: body.get("file") }, who.name, now);
|
|
587
|
+
if (saved.ok)
|
|
588
|
+
await prepareUnattendedCandidates(store, flow.id);
|
|
584
589
|
return saved.ok ? settle(saved.said) : answer(400, { ok: false, said: saved.message });
|
|
585
590
|
}
|
|
586
591
|
if (action === "linear-key" || action === "hooks-address") {
|
|
@@ -604,6 +609,19 @@ export function createFlowsHandlers(runtime) {
|
|
|
604
609
|
store.archiveFlow(flow.id, who.name, now);
|
|
605
610
|
return redirect(response, "/flows");
|
|
606
611
|
}
|
|
612
|
+
if (action === "unattended") {
|
|
613
|
+
// The owner's password signs exactly the terms the panel showed; a flow saved meanwhile is refused (sawRevision).
|
|
614
|
+
if (!authenticateApprover(who, body.get("password") ?? "").ok)
|
|
615
|
+
return answer(403, { ok: false, said: "That password isn't right. Nothing was signed." });
|
|
616
|
+
const asked = parseUnattendedAsk({ days: body.get("days"), budget: body.get("budget"), runs: body.get("runs"), include: body.get("include") });
|
|
617
|
+
if (!asked.ok)
|
|
618
|
+
return answer(400, { ok: false, said: asked.message });
|
|
619
|
+
const signed = await signUnattended(store, { flow: flow.id, signer: who.name, ask: asked.ask, sawRevision: Number(body.get("revision")), sawSchedules: body.get("schedules") ?? "" }, now);
|
|
620
|
+
return signed.ok ? settle(`Runs on its own until ${untilWords(signed.grant.absoluteExpiry)}.`) : answer(409, { ok: false, said: signed.message });
|
|
621
|
+
}
|
|
622
|
+
if (action === "unattended-off") {
|
|
623
|
+
return revokeUnattended(store, flow.id, who.name, now) ? settle("It no longer runs on its own. New cards wait for you.") : answer(409, { ok: false, said: "It wasn't running on its own." });
|
|
624
|
+
}
|
|
607
625
|
if (action === "save") {
|
|
608
626
|
let saved, drawn;
|
|
609
627
|
try {
|
|
@@ -637,6 +655,8 @@ export function createFlowsHandlers(runtime) {
|
|
|
637
655
|
if (dir === null)
|
|
638
656
|
return answer(409, { ok: false, said: "Secrets are kept on the console's computer." });
|
|
639
657
|
const saved = setFlowSecret(dir, flow.repo, (body.get("name") ?? "").trim(), body.get("value") ?? "");
|
|
658
|
+
if (saved.ok)
|
|
659
|
+
await prepareUnattendedCandidates(store, flow.id);
|
|
640
660
|
return saved.ok ? settle(saved.said) : answer(400, { ok: false, said: saved.message });
|
|
641
661
|
}
|
|
642
662
|
// Adding a card.
|
|
@@ -702,11 +722,11 @@ export function createFlowsHandlers(runtime) {
|
|
|
702
722
|
if (trigger === null || trigger.flow !== flow.id || trigger.state === "removed")
|
|
703
723
|
return answer(404, { ok: false, said: "That trigger isn't on this flow." });
|
|
704
724
|
if (verb === "pause" || verb === "resume") {
|
|
705
|
-
setFlowTriggerOn(store, trigger, verb === "resume", now);
|
|
725
|
+
setFlowTriggerOn(store, trigger, verb === "resume", now, who.name);
|
|
706
726
|
return settle(verb === "pause" ? "Trigger paused." : "Trigger on again.");
|
|
707
727
|
}
|
|
708
728
|
if (verb === "remove") {
|
|
709
|
-
removeFlowTrigger(store, trigger, now, dir);
|
|
729
|
+
removeFlowTrigger(store, trigger, now, dir, who.name);
|
|
710
730
|
return settle("Trigger removed.");
|
|
711
731
|
}
|
|
712
732
|
if (verb === "share") {
|
|
@@ -887,6 +907,8 @@ export function createFlowsHandlers(runtime) {
|
|
|
887
907
|
"flow.linear-key": flowAct("linear-key"),
|
|
888
908
|
"flow.hooks-address": flowAct("hooks-address"),
|
|
889
909
|
"flow.secrets": flowAct("secrets"),
|
|
910
|
+
"flow.unattended": flowAct("unattended"),
|
|
911
|
+
"flow.unattended-off": flowAct("unattended-off"),
|
|
890
912
|
"flow.triggers": flowAct("triggers"),
|
|
891
913
|
"flow.card.move": cardAct("move"),
|
|
892
914
|
"flow.card.decide": cardAct("decide"),
|
|
@@ -3,6 +3,7 @@ import { type Html } from "../html.js";
|
|
|
3
3
|
import { type RunActivity } from "../activity-line.js";
|
|
4
4
|
import { type AgentChoice } from "../agentconfig.js";
|
|
5
5
|
import { type AssignmentSnapshot } from "../assignment.js";
|
|
6
|
+
import { type TaskBaseView } from "../task-base.js";
|
|
6
7
|
import { type BrowserTaskView } from "../browser-workspace.js";
|
|
7
8
|
import { type FormView } from "../contracts/console-api.js";
|
|
8
9
|
import { type DispatchDiagnosis } from "../dispatch.js";
|
|
@@ -588,6 +589,8 @@ export declare function taskBodyParts(data: {
|
|
|
588
589
|
/** The pull request opened through Complete (its state, link, CI and merge), or the offer to open one for a
|
|
589
590
|
* result already marked complete; null when neither applies. */
|
|
590
591
|
pullRequest?: TaskPullRequest | null;
|
|
592
|
+
/** Fresh base: the commit this task's work starts from, and how far current main has moved past it. */
|
|
593
|
+
base?: TaskBaseView | null;
|
|
591
594
|
/** The task's whole family (the original and its revisions) and every attempt across it, for the thread. */
|
|
592
595
|
family?: {
|
|
593
596
|
root: {
|
|
@@ -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)
|
|
@@ -238,6 +238,9 @@ for (const one of ["save", "cards", "archive", "scripts"])
|
|
|
238
238
|
post("flows", `flow.${one}`, `^\\/flows\\/${FLOW}\\/${one}$`, { project: "flow", limited: "resource", sample: `/flows/1/${one}`, ...EDIT_FLOWS });
|
|
239
239
|
for (const one of ["linear-key", "hooks-address", "secrets"])
|
|
240
240
|
post("flows", `flow.${one}`, `^\\/flows\\/${FLOW}\\/${one}$`, { scope: "step-up", project: "flow", sample: `/flows/1/${one}`, ...EDIT_FLOWS });
|
|
241
|
+
// "Runs on its own": signing raises authority (the owner's password, a step-up); revoking is one click for any approver.
|
|
242
|
+
post("flows", "flow.unattended", `^\\/flows\\/${FLOW}\\/unattended$`, { scope: "step-up", project: "flow", sample: "/flows/1/unattended", ...EDIT_FLOWS });
|
|
243
|
+
post("flows", "flow.unattended-off", `^\\/flows\\/${FLOW}\\/unattended-off$`, { project: "flow", sample: "/flows/1/unattended-off", ...EDIT_FLOWS });
|
|
241
244
|
post("flows", "flow.triggers", `^\\/flows\\/${FLOW}\\/triggers$`, { project: "flow", limited: "resource", sample: "/flows/1/triggers", ...EDIT_FLOWS });
|
|
242
245
|
for (const one of ["move", "decide", "cancel", "comment", "assign", "watch"])
|
|
243
246
|
post("flows", `flow.card.${one}`, `^\\/flows\\/${FLOW}\\/cards\\/${FLOW}\\/${one}$`, { project: "flow", limited: "resource", sample: `/flows/1/cards/2/${one}`, ...EDIT_FLOWS });
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -107,7 +107,7 @@ export interface ServerRuntime {
|
|
|
107
107
|
consoleProjects: () => string[];
|
|
108
108
|
flowRooms: FlowRooms;
|
|
109
109
|
providerHome: string;
|
|
110
|
-
toolHome: string;
|
|
110
|
+
toolHome: string | undefined;
|
|
111
111
|
leadPrincipal: (who: Who & {
|
|
112
112
|
via: "cookie";
|
|
113
113
|
}) => VerifiedApprover | null;
|
package/dist/server/settings.js
CHANGED
|
@@ -82,7 +82,8 @@ import { applySavedKnowledge, changeKnowledge, knowledgeVersion, knowledgeView }
|
|
|
82
82
|
import { changeLearning } from "../project-learning.js";
|
|
83
83
|
import { listDecisions, recordDecision, retireDecision, searchMemory } from "../project-memory.js";
|
|
84
84
|
import { changeSkills, githubSkill, importSkill, reviseSkillTest, skillsView, testSkill } from "../project-skills.js";
|
|
85
|
-
import {
|
|
85
|
+
import { signedIn } from "../oauth-run-token.js";
|
|
86
|
+
import { addToolTo, catalogTool, localAppOf, removeToolFrom, setToolSecret, splitCommandLine, testToolOf, toolSecretStorage } from "../project-tools.js";
|
|
86
87
|
import { liftAuthPause } from "../provider-auth.js";
|
|
87
88
|
import { isProviderId, PROVIDER_IDS, validateSpec, validModelId } from "../provider.js";
|
|
88
89
|
import { checkPublishing, MERGE_METHODS, saveMergeSettings, savePublishing } from '../pull-request-flow.js';
|
|
@@ -104,6 +105,7 @@ import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as
|
|
|
104
105
|
import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
|
|
105
106
|
import { ssoSettingsHtml } from "../sso-ui.js";
|
|
106
107
|
import { lastSweep, saveSweep, storageSweepOff } from "../storage-sweep.js";
|
|
108
|
+
import { sweepSchedule } from "../storage-sweep-schedule.js";
|
|
107
109
|
import { storageHtml } from "../storage-ui.js";
|
|
108
110
|
import { landedSummary } from "../landed-cleanup.js";
|
|
109
111
|
import { bytesWords, parseCleanup } from "../storage.js";
|
|
@@ -302,7 +304,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
302
304
|
return refuse(response, who, 403, "That project is outside your access.", "/settings/project");
|
|
303
305
|
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
306
|
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));
|
|
307
|
+
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
308
|
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
309
|
}
|
|
308
310
|
// v102: Settings → Approval rules, a project's separation of duties. Anyone who sees the project reads them; an instance operator sets them.
|
|
@@ -349,7 +351,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
349
351
|
if (!options.configDir)
|
|
350
352
|
return refuse(response, who, 403, "An installation approver sees integrations.", "/settings");
|
|
351
353
|
const list = integrations.list();
|
|
352
|
-
return sendScreen(response, 200, screen("Integrations", html `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking }))}`, { chrome: chromeFor(project, "settings") }));
|
|
354
|
+
return sendScreen(response, 200, screen("Integrations", html `<p><a href="/settings">Settings</a></p><h1>Integrations</h1>${(integrationsHtml(list, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem"), checking: integrations.checking, secrets: toolSecretStorage() }))}`, { chrome: chromeFor(project, "settings") }));
|
|
353
355
|
}
|
|
354
356
|
// v104: Settings → Monitoring, where the audit stream and traces go. An instance operator's page.
|
|
355
357
|
async function monitoringPage(ctx) {
|
|
@@ -398,7 +400,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
|
|
|
398
400
|
if (databaseFile === null)
|
|
399
401
|
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
400
402
|
const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
|
|
401
|
-
const sweep = { last: lastSweep(store), off: storageSweepOff() };
|
|
403
|
+
const sweep = { last: lastSweep(store), off: storageSweepOff(), schedule: sweepSchedule(store, now) };
|
|
402
404
|
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
405
|
}
|
|
404
406
|
// Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
|
|
@@ -623,7 +625,12 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
623
625
|
}, who.role === "approver" && csrf !== "" ? (() => {
|
|
624
626
|
// v87: the mail server flows send email through; the password is never shown back.
|
|
625
627
|
// v89: where Inbox triggers read it, and a connected Google account (its client secret is never shown back either).
|
|
626
|
-
const email = readEmailSettings(options.configDir ?? null), google =
|
|
628
|
+
const email = readEmailSettings(options.configDir ?? null), google = (() => { try {
|
|
629
|
+
return readGoogleMail(options.configDir ?? null);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null;
|
|
633
|
+
} })(), origin = consoleOrigin(request.headers.host);
|
|
627
634
|
const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
|
|
628
635
|
return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
|
|
629
636
|
: { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
|
|
@@ -1396,6 +1403,8 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
|
|
|
1396
1403
|
const secret = body.get("secret") ?? "";
|
|
1397
1404
|
if (!tool.spec.secrets.some(one => one.name === secret))
|
|
1398
1405
|
return back("problem", "Choose one of this tool's secrets.");
|
|
1406
|
+
if (signedIn(tool.spec))
|
|
1407
|
+
return back("problem", `${name} is connected by signing in. Sign in again from Connect to renew it.`, `#tool-${name}`);
|
|
1399
1408
|
setToolSecret(repo, name, secret, body.get("value") ?? "", toolHome);
|
|
1400
1409
|
return back("said", `Saved ${secret} for ${name}. It is never shown again.`, `#tool-${name}`);
|
|
1401
1410
|
}
|
|
@@ -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
|
|
@@ -3,7 +3,7 @@ import { Worker } from "node:worker_threads";
|
|
|
3
3
|
import { openStoreNoMigrate } from "./store.js";
|
|
4
4
|
import { WorktreePool } from "./worktree.js";
|
|
5
5
|
import { executeSweep } from "./storage-sweep.js";
|
|
6
|
-
import { sweepRequest } from "./storage-sweep-process.js";
|
|
6
|
+
import { enterSweepBackground, sweepRequest } from "./storage-sweep-process.js";
|
|
7
7
|
const parent = process.ppid;
|
|
8
8
|
let store;
|
|
9
9
|
let finished = false;
|
|
@@ -57,6 +57,8 @@ process.once("message", async (request) => {
|
|
|
57
57
|
finish(1);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
+
// Node and the sweep's code are loaded: from here on, on macOS, the work and everything it starts runs in the background.
|
|
61
|
+
enterSweepBackground();
|
|
60
62
|
try {
|
|
61
63
|
const opened = openStoreNoMigrate(request.databaseFile);
|
|
62
64
|
if (!opened.ok)
|
|
@@ -29,6 +29,20 @@ 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
|
+
/**
|
|
35
|
+
* How the child enters the background policy on macOS, or null elsewhere (or without taskpolicy): the child runs it on
|
|
36
|
+
* its own pid once Node has started and loaded the sweep, before any sweep work. Everything it starts after that (rm,
|
|
37
|
+
* du, git) inherits the policy. Starting Node itself under it let a busy machine hold the child's start for as long
|
|
38
|
+
* as anything else wanted the CPU, before it had done any of the work the policy is for.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sweepBackground(pid: number, platform?: NodeJS.Platform, exists?: (path: string) => boolean): {
|
|
41
|
+
file: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
} | null;
|
|
44
|
+
/** In the child: the background policy from now on. Without it the sweep still runs, at the nice its parent set. */
|
|
45
|
+
export declare function enterSweepBackground(run?: (file: string, args: string[]) => void): boolean;
|
|
32
46
|
/** Owns one child without lending its lifetime to a reconcile pass or its database connection. */
|
|
33
47
|
export declare function createSweepSupervisor(options?: {
|
|
34
48
|
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
|
-
import { fork } from "node:child_process";
|
|
2
|
+
import { execFileSync, 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,45 @@ 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
|
+
/**
|
|
32
|
+
* How the child enters the background policy on macOS, or null elsewhere (or without taskpolicy): the child runs it on
|
|
33
|
+
* its own pid once Node has started and loaded the sweep, before any sweep work. Everything it starts after that (rm,
|
|
34
|
+
* du, git) inherits the policy. Starting Node itself under it let a busy machine hold the child's start for as long
|
|
35
|
+
* as anything else wanted the CPU, before it had done any of the work the policy is for.
|
|
36
|
+
*/
|
|
37
|
+
export function sweepBackground(pid, platform = process.platform, exists = existsSync) {
|
|
38
|
+
return platform === "darwin" && exists(TASKPOLICY) ? { file: TASKPOLICY, args: ["-b", "-p", String(pid)] } : null;
|
|
39
|
+
}
|
|
40
|
+
/** In the child: the background policy from now on. Without it the sweep still runs, at the nice its parent set. */
|
|
41
|
+
export function enterSweepBackground(run = (file, args) => { execFileSync(file, args, { stdio: "ignore", timeout: 5_000 }); }) {
|
|
42
|
+
const command = sweepBackground(process.pid);
|
|
43
|
+
if (command === null)
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
run(command.file, command.args);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
26
53
|
/** Only source checkouts need tsx; installed builds use the sibling child and the same Node. */
|
|
27
54
|
function forkSweep() {
|
|
28
55
|
const source = import.meta.url.endsWith(".ts");
|
|
29
56
|
const child = fork(fileURLToPath(new URL(source ? "./storage-sweep-child.ts" : "./storage-sweep-child.js", import.meta.url)), [], {
|
|
30
57
|
execArgv: source ? ["--import", "tsx"] : [], stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true,
|
|
31
58
|
});
|
|
32
|
-
//
|
|
59
|
+
// Before the request is sent, so every rm/du the child starts inherits it.
|
|
60
|
+
if (child.pid !== undefined) {
|
|
61
|
+
try {
|
|
62
|
+
setPriority(child.pid, SWEEP_NICE);
|
|
63
|
+
}
|
|
64
|
+
catch { /* the default priority still works */ }
|
|
65
|
+
}
|
|
66
|
+
// The service never waits on the child to exit; stop() and the child's parent-loss guard end it.
|
|
33
67
|
child.unref();
|
|
34
68
|
child.channel?.unref();
|
|
35
69
|
return child;
|
|
@@ -165,7 +199,7 @@ export function createSweepSupervisor(options = {}) {
|
|
|
165
199
|
},
|
|
166
200
|
};
|
|
167
201
|
}
|
|
168
|
-
// All watch loops in this process share a flight. Other
|
|
202
|
+
// All watch loops in this process share a flight. Other services arbitrate the claim in SQLite.
|
|
169
203
|
const supervisors = new Map();
|
|
170
204
|
export function storageSweepSupervisor(databaseFile) {
|
|
171
205
|
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
|