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.
Files changed (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +31 -11
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. package/scripts/provider-canary.mjs +210 -31
@@ -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 });
@@ -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;
@@ -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 { addToolTo, catalogTool, localAppOf, removeToolFrom, setToolSecret, splitCommandLine, testToolOf } from "../project-tools.js";
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 = readGoogleMail(options.configDir ?? null), origin = consoleOrigin(request.headers.host);
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
- // A one-shot CLI may exit after reconcile; the child's parent-loss guard then ends it.
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 workers arbitrate the daily claim in SQLite.
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
+ }
@@ -1,8 +1,9 @@
1
1
  import { type Located } from "./orphan-sweep.js";
2
2
  import { type ProcessRow } from "./run-closeout.js";
3
- import { type Store } from "./store.js";
3
+ import type { Store } from "./store.js";
4
4
  import { type WorktreePool } from "./worktree.js";
5
- import { type SweepSupervisor, type SweepExecutor } from "./storage-sweep-process.js";
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 storageSweepDue(store: Store, now: Date): boolean;
40
- /** Claim and launch only. A hung child never holds reconciliation or the caller's store open. */
41
- export declare function dailyStorageSweep(store: Store, deps: SweepDeps, now: () => Date, supervisor?: SweepSupervisor): SweepRecord | null;
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