toolroll 0.9.62 → 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 (100) hide show
  1. package/CHANGELOG.md +29 -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 +3 -0
  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 +3 -2
  49. package/dist/landed-cleanup.js +12 -5
  50. package/dist/lead-tools.js +11 -3
  51. package/dist/mcp-connect.d.ts +8 -7
  52. package/dist/mcp-connect.js +49 -51
  53. package/dist/oauth-run-token.d.ts +51 -0
  54. package/dist/oauth-run-token.js +127 -0
  55. package/dist/operate.js +7 -1
  56. package/dist/plan-auto.d.ts +11 -0
  57. package/dist/plan-auto.js +62 -11
  58. package/dist/process-recovery-eligibility.d.ts +1 -1
  59. package/dist/project-access.d.ts +3 -0
  60. package/dist/project-access.js +3 -0
  61. package/dist/project-delete.d.ts +3 -1
  62. package/dist/project-delete.js +10 -4
  63. package/dist/project-file.d.ts +3 -0
  64. package/dist/project-file.js +16 -0
  65. package/dist/project-tools.d.ts +99 -25
  66. package/dist/project-tools.js +394 -98
  67. package/dist/provider-home.d.ts +22 -0
  68. package/dist/provider-home.js +99 -0
  69. package/dist/provider.d.ts +17 -3
  70. package/dist/provider.js +34 -10
  71. package/dist/remote-main.d.ts +4 -0
  72. package/dist/remote-main.js +3 -3
  73. package/dist/scope.d.ts +1 -1
  74. package/dist/serve.js +11 -3
  75. package/dist/server/flows.js +27 -5
  76. package/dist/server/route-table.js +3 -0
  77. package/dist/server/runtime.d.ts +1 -1
  78. package/dist/server/settings.js +11 -3
  79. package/dist/storage-sweep-child.js +3 -1
  80. package/dist/storage-sweep-process.d.ts +12 -5
  81. package/dist/storage-sweep-process.js +23 -6
  82. package/dist/store.d.ts +38 -6
  83. package/dist/store.js +148 -15
  84. package/dist/subagent-tools.d.ts +1 -1
  85. package/dist/surface.js +6 -4
  86. package/dist/teardown-git.d.ts +14 -0
  87. package/dist/teardown-git.js +119 -0
  88. package/dist/tool-broker.d.ts +37 -0
  89. package/dist/tool-broker.js +369 -0
  90. package/dist/tool-launcher.js +5 -4
  91. package/dist/tool-secret-store.d.ts +170 -0
  92. package/dist/tool-secret-store.js +834 -0
  93. package/dist/toolroll-update.js +3 -2
  94. package/dist/tools-ui.d.ts +7 -1
  95. package/dist/tools-ui.js +17 -8
  96. package/dist/work-index.js +5 -3
  97. package/dist/worktree.d.ts +10 -4
  98. package/dist/worktree.js +70 -29
  99. package/package.json +3 -1
  100. package/scripts/provider-canary.mjs +210 -31
@@ -51,11 +51,11 @@ export declare function triggerConfigOf(trigger: FlowTriggerRow): TriggerConfig
51
51
  export declare function readTriggerConfig(raw: unknown): ContractResult<TriggerConfig>;
52
52
  /** Whether this trigger takes deliveries at a secret address rather than being checked. */
53
53
  export declare function takesDeliveries(config: TriggerConfig): boolean;
54
- /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). 0600, beside the database, never a column. */
54
+ /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). In the keychain (or an owner-only file where there's none), never a column. */
55
55
  export declare function saveHookSecret(dir: string, trigger: number, secret: string): void;
56
56
  export declare function readHookSecret(dir: string, trigger: number): string | null;
57
57
  export declare const LINEAR_ENV = "TOOLROLL_LINEAR_KEY";
58
- /** The Linear API key the worker checks Linear with: the environment, or a 0600 file beside the database. */
58
+ /** The Linear API key the worker checks Linear with: the environment, or kept by Toolroll (the keychain, or an owner-only file beside the database). */
59
59
  export declare function readLinearKey(dir: string | null): string | null;
60
60
  export declare function saveLinearKey(dir: string, key: string): {
61
61
  ok: true;
@@ -96,13 +96,17 @@ export declare function renewFlowHook(store: Store, trigger: FlowTriggerRow, now
96
96
  * Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
97
97
  * for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
98
98
  */
99
- export declare function setFlowTriggerOn(store: Store, trigger: FlowTriggerRow, on: boolean, now: Date): void;
100
- export declare function removeFlowTrigger(store: Store, trigger: FlowTriggerRow, now: Date, dir: string | null): void;
99
+ export declare function setFlowTriggerOn(store: Store, trigger: FlowTriggerRow, on: boolean, now: Date, by?: string): void;
100
+ export declare function removeFlowTrigger(store: Store, trigger: FlowTriggerRow, now: Date, dir: string | null, by?: string): void;
101
+ /** `onTime`: the clock's own firing of a schedule (never Run now or Check now); only those can run on their own.
102
+ * `ranScript`: for a script's, what code it ran (scriptRanDigestOf), which must be the code a grant signed. */
101
103
  export type Incoming = {
102
104
  key: string;
103
105
  title: string;
104
106
  description: string | null;
105
107
  source: FlowCardSource;
108
+ onTime?: boolean;
109
+ ranScript?: string | null;
106
110
  };
107
111
  type Made = "added" | "seen" | "skipped";
108
112
  /** One outside thing into one card through a trigger (a chat channel's message, v89): at most once per key. */
@@ -31,7 +31,8 @@ import { html, htmlString, postForm, styleElement } from "./html.js";
31
31
  * and a card's work is an ordinary task under the usual approvals.
32
32
  */
33
33
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
34
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
34
+ import { readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
35
+ import { SecretStoreError, readHeldSecret, writeHeldSecret } from "./tool-secret-store.js";
35
36
  import { join } from "node:path";
36
37
  import { scanForSecrets } from "./evidence.js";
37
38
  import { flowCardText, flowDefinitionOf } from "./flow-engine.js";
@@ -42,6 +43,7 @@ import { describeSchedule, fileStandingOrder, firstFireAt, nextFireAt, parseSche
42
43
  import { takeReply } from "./flow-replies.js";
43
44
  import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./mailbox.js";
44
45
  import { readFlowSecrets } from "./flow-secrets.js";
46
+ import { prepareUnattendedCandidates, grantSignsScriptOf, scriptProvenanceOf, scriptRanDigestOf } from "./flow-unattended.js";
45
47
  import { tmpdir } from "node:os";
46
48
  import { envValue } from "./names.js";
47
49
  import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
@@ -330,26 +332,33 @@ export function takesDeliveries(config) {
330
332
  }
331
333
  // ------------------------------------------------------ secrets on disk
332
334
  const hookHash = (token) => createHash("sha256").update(token).digest("hex");
333
- const secretFile = (dir, trigger) => join(dir, "flow-hooks", `${trigger}.json`);
334
- /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). 0600, beside the database, never a column. */
335
+ /** A webhook's signing secret: kept by Toolroll (tool-secret-store.ts) as `flow-hook.<trigger>`, in a file of that name before. */
336
+ const hookSecret = (trigger) => ({ name: `flow-hook.${trigger}`, file: ["flow-hooks", `${trigger}.json`] });
337
+ /** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). In the keychain (or an owner-only file where there's none), never a column. */
335
338
  export function saveHookSecret(dir, trigger, secret) {
336
- mkdirSync(join(dir, "flow-hooks"), { recursive: true, mode: 0o700 });
337
- writeFileSync(secretFile(dir, trigger), `${JSON.stringify({ secret })}\n`, { mode: 0o600 });
338
- chmodSync(secretFile(dir, trigger), 0o600);
339
+ const held = hookSecret(trigger);
340
+ writeHeldSecret(dir, held.name, held.file, `${JSON.stringify({ secret })}\n`);
339
341
  }
340
342
  export function readHookSecret(dir, trigger) {
343
+ const held = hookSecret(trigger);
341
344
  try {
342
- const parsed = JSON.parse(readFileSync(secretFile(dir, trigger), "utf8"));
343
- return typeof parsed.secret === "string" && parsed.secret !== "" ? parsed.secret : null;
345
+ const parsed = JSON.parse(readHeldSecret(dir, held.name, held.file) ?? "null");
346
+ return typeof parsed?.secret === "string" && parsed.secret !== "" ? parsed.secret : null;
344
347
  }
345
348
  catch {
346
349
  return null;
347
350
  }
348
351
  }
349
- function dropHookSecret(dir, trigger) { rmSync(secretFile(dir, trigger), { force: true }); }
350
- const LINEAR_FILE = "linear-key";
352
+ function dropHookSecret(dir, trigger) {
353
+ const held = hookSecret(trigger);
354
+ try {
355
+ writeHeldSecret(dir, held.name, held.file, null);
356
+ }
357
+ catch { /* the trigger is gone; an orphan is never read */ }
358
+ }
359
+ const LINEAR = { name: "linear-key", file: ["linear-key"] };
351
360
  export const LINEAR_ENV = "TOOLROLL_LINEAR_KEY";
352
- /** The Linear API key the worker checks Linear with: the environment, or a 0600 file beside the database. */
361
+ /** The Linear API key the worker checks Linear with: the environment, or kept by Toolroll (the keychain, or an owner-only file beside the database). */
353
362
  export function readLinearKey(dir) {
354
363
  const env = envValue(process.env, "LINEAR_KEY")?.trim();
355
364
  if (env)
@@ -357,7 +366,7 @@ export function readLinearKey(dir) {
357
366
  if (dir === null)
358
367
  return null;
359
368
  try {
360
- const key = readFileSync(join(dir, LINEAR_FILE), "utf8").trim();
369
+ const key = (readHeldSecret(dir, LINEAR.name, LINEAR.file) ?? "").trim();
361
370
  return key === "" ? null : key;
362
371
  }
363
372
  catch {
@@ -368,11 +377,15 @@ export function saveLinearKey(dir, key) {
368
377
  const clean = key.trim();
369
378
  if (!/^lin_(api|oauth)_[A-Za-z0-9]{20,}$/.test(clean))
370
379
  return { ok: false, message: "That doesn't look like a Linear API key. Copy it from Linear → Settings → Security & access → Personal API keys." };
371
- writeFileSync(join(dir, LINEAR_FILE), `${clean}\n`, { mode: 0o600 });
372
- chmodSync(join(dir, LINEAR_FILE), 0o600);
380
+ try {
381
+ writeHeldSecret(dir, LINEAR.name, LINEAR.file, `${clean}\n`);
382
+ }
383
+ catch (error) {
384
+ return { ok: false, message: error instanceof SecretStoreError ? error.message : "The key couldn't be saved." };
385
+ }
373
386
  return { ok: true };
374
387
  }
375
- export function removeLinearKey(dir) { rmSync(join(dir, LINEAR_FILE), { force: true }); }
388
+ export function removeLinearKey(dir) { writeHeldSecret(dir, LINEAR.name, LINEAR.file, null); }
376
389
  const HOOKS_FILE = "hooks-url";
377
390
  /** The public address webhooks reach this console at (a reverse proxy such as Caddy, exposing /hooks/ only). */
378
391
  export function readHooksBase(dir) {
@@ -457,13 +470,13 @@ export function renewFlowHook(store, trigger, now, dir) {
457
470
  * Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
458
471
  * for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
459
472
  */
460
- export function setFlowTriggerOn(store, trigger, on, now) {
473
+ export function setFlowTriggerOn(store, trigger, on, now, by) {
461
474
  const config = on && trigger.nextAt === null ? triggerConfigOf(trigger) : null;
462
475
  const schedule = config !== null && (config.kind === "schedule" || config.kind === "plane-review") ? parseSchedule(config.schedule) : null;
463
- store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now);
476
+ store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now, by);
464
477
  }
465
- export function removeFlowTrigger(store, trigger, now, dir) {
466
- store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now);
478
+ export function removeFlowTrigger(store, trigger, now, dir, by) {
479
+ store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now, by);
467
480
  if (dir !== null)
468
481
  dropHookSecret(dir, trigger.id);
469
482
  }
@@ -506,7 +519,7 @@ function makeCard(store, trigger, config, item, by, now) {
506
519
  return { made: "skipped", note: `${item.source.label}: ${note}`, card: null };
507
520
  }
508
521
  const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: startZone(config, definition), by, source: item.source }, now);
509
- store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now);
522
+ store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, null, item.onTime === true, item.ranScript ?? null);
510
523
  return { made: "added", note: null, card };
511
524
  });
512
525
  }
@@ -587,7 +600,10 @@ export function pressFlowButton(store, trigger, answers, actor, now) {
587
600
  /** Every trigger in one project that is due: schedules and other flows here, GitHub, Linear and email by asking them. */
588
601
  export async function runFlowTriggers(store, repo, now, io) {
589
602
  const pass = { added: 0, checked: 0, problems: [] };
590
- for (const trigger of store.activeFlowTriggers(repo)) {
603
+ const triggers = store.activeFlowTriggers(repo);
604
+ for (const flow of new Set(triggers.map(one => one.flow)))
605
+ await prepareUnattendedCandidates(store, flow);
606
+ for (const trigger of triggers) {
591
607
  const config = triggerConfigOf(trigger);
592
608
  if (config === null)
593
609
  continue;
@@ -655,7 +671,7 @@ function fireSchedule(store, trigger, config, now) {
655
671
  const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: schedule.kind === "every" ? "UTC" : schedule.timezone ?? "UTC" }).format(new Date(slot));
656
672
  const fill = (text) => text.replace(/\{\{\s*date\s*\}\}/g, day);
657
673
  const title = /\{\{\s*date\s*\}\}/.test(config.title) ? fill(config.title) : `${config.title} — ${day}`;
658
- const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null } };
674
+ const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null }, onTime: true };
659
675
  const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, slot, now);
660
676
  store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? made.note ?? "Added a card." : made.note ?? "Nothing new." }, now);
661
677
  return made.made === "added" ? 1 : 0;
@@ -713,7 +729,7 @@ export async function firePlaneReview(store, trigger, config, now, scheduled) {
713
729
  continue;
714
730
  }
715
731
  const text = problemCard(problem, dayWords);
716
- const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null } }, "Plane review", now);
732
+ const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null }, onTime: scheduled }, "Plane review", now);
717
733
  if (made.made === "added")
718
734
  added++;
719
735
  else if (made.note !== null)
@@ -768,6 +784,9 @@ async function fireScript(store, trigger, config, now, io, scheduled) {
768
784
  const { agentFence } = await import("./agent-fence.js");
769
785
  const scratch = io.scratch ?? join(io.dir ?? tmpdir(), "flow-scratch");
770
786
  const folder = cleanFolder(scratch, `flow-trigger-${trigger.id}`);
787
+ // Its cards run on their own only if it ran the very code a grant signed: pinned just before and just after the run.
788
+ const pin = scheduled && grantSignsScriptOf(store, flow.id, trigger.id, now) ? () => scriptProvenanceOf(store, flow.repo, script.name) : null;
789
+ const before = pin === null ? null : await pin();
771
790
  let ran;
772
791
  try {
773
792
  ran = await runCode({ script, cwd: folder, root: flow.repo, scratch, shell: io.shell ?? io.gh,
@@ -780,9 +799,10 @@ async function fireScript(store, trigger, config, now, io, scheduled) {
780
799
  }
781
800
  if (ran.state === "failed")
782
801
  return done(false, ran.said.split("\n")[0]);
802
+ const ranScript = pin === null ? null : scriptRanDigestOf(before, await pin(), script);
783
803
  const items = cardsFromOutput(ran.printed);
784
804
  const results = items.slice(0, CARDS_PER_CHECK).map(item => makeCard(store, trigger, config, {
785
- key: `script:${item.key}`, title: item.title, description: item.description, source: { kind: "script", label: `The ${script.name} script`, url: null },
805
+ key: `script:${item.key}`, title: item.title, description: item.description, source: { kind: "script", label: `The ${script.name} script`, url: null }, onTime: scheduled, ranScript,
786
806
  }, `The ${script.name} script`, now));
787
807
  return done(true, items.length === 0 ? `${script.name} ran and printed nothing to add.` : outcomeWords(results, items.length > CARDS_PER_CHECK), results.filter(one => one.made === "added").length);
788
808
  }
@@ -1090,7 +1110,7 @@ export function hookReady(trigger, dir) {
1090
1110
  return false;
1091
1111
  if (config.kind === "webhook")
1092
1112
  return true;
1093
- return dir !== null && existsSync(secretFile(dir, trigger.id));
1113
+ return dir !== null && readHookSecret(dir, trigger.id) !== null;
1094
1114
  }
1095
1115
  // ------------------------------------------------------ public forms (v84)
1096
1116
  export const FORM_PATH = "/hooks/form/";
@@ -0,0 +1,28 @@
1
+ /** The words a "runs on its own" grant signs (flow-unattended.ts), kept free of server imports so the flow page's
2
+ * signing form says each limit exactly as the signature will. */
3
+ export declare const dollarWords: (micro: number) => string;
4
+ /** "Oct 19": the end's day, where space is short (the exact time goes beside it, on hover). */
5
+ export declare const untilDayWords: (iso: string) => string;
6
+ /** "Oct 19, 14:05 UTC": the expiry as every surface says it. */
7
+ export declare const untilWords: (iso: string) => string;
8
+ /** Who approves, with what permissions and against which criteria. */
9
+ export declare function unattendedHeadWords(owner: string, acceptance: {
10
+ task: readonly {
11
+ statement: string;
12
+ }[];
13
+ report: readonly {
14
+ statement: string;
15
+ }[];
16
+ }): string[];
17
+ /** "daily at 09:00" from a stored schedule ("daily:09:00@Europe/London"), kept here so every surface says it alike. */
18
+ export declare function scheduleWords(stored: string): string;
19
+ /** Which schedules the grant covers, by name and time. */
20
+ export declare const unattendedCoverWords: (triggers: readonly {
21
+ words: string;
22
+ }[]) => string;
23
+ /** The optional limits. */
24
+ export declare function unattendedLimitWords(budgetMicrousd: number | null, dailyRunCap: number | null): string[];
25
+ /** What it never covers, and what ends it. */
26
+ export declare const UNATTENDED_RULE_WORDS: readonly string[];
27
+ export declare const unattendedEndWords: (expiry: string) => string;
28
+ export declare const unattendedEndAfterWords: (days: number) => string;
@@ -0,0 +1,50 @@
1
+ /** The words a "runs on its own" grant signs (flow-unattended.ts), kept free of server imports so the flow page's
2
+ * signing form says each limit exactly as the signature will. */
3
+ export const dollarWords = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
4
+ /** "Oct 19": the end's day, where space is short (the exact time goes beside it, on hover). */
5
+ export const untilDayWords = (iso) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(iso));
6
+ /** "Oct 19, 14:05 UTC": the expiry as every surface says it. */
7
+ export const untilWords = (iso) => `${new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(iso))}, ${iso.slice(11, 16)} UTC`;
8
+ /** Who approves, with what permissions and against which criteria. */
9
+ export function unattendedHeadWords(owner, acceptance) {
10
+ return [
11
+ `Cards this flow's own schedule adds file their work approved by ${owner}, without waiting in Needs you.`,
12
+ "Builds use safe permissions: routine project commands and edits proceed; risky acts stop for approval.",
13
+ `Builds are checked against: “${acceptance.task.map(one => one.statement).join("”; “")}”.`,
14
+ `Research is checked against: “${acceptance.report.map(one => one.statement).join("”; “")}”.`,
15
+ ];
16
+ }
17
+ /** "daily at 09:00" from a stored schedule ("daily:09:00@Europe/London"), kept here so every surface says it alike. */
18
+ export function scheduleWords(stored) {
19
+ const days = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
20
+ const [when, zone] = stored.split("@");
21
+ const parts = (when ?? "").split(":");
22
+ const tz = zone === undefined ? "" : ` ${zone}`;
23
+ if (parts[0] === "every") {
24
+ const n = Number(parts[1]);
25
+ return n % 1440 === 0 ? `every ${n / 1440} day${n === 1440 ? "" : "s"}` : n % 60 === 0 ? `every ${n / 60} hour${n === 60 ? "" : "s"}` : `every ${n} minutes`;
26
+ }
27
+ if (parts[0] === "daily" || parts[0] === "weekdays")
28
+ return `${parts[0]} at ${parts[1]}:${parts[2]}${tz}`;
29
+ if (parts[0] === "weekly")
30
+ return `${days[Number(parts[1])] ?? "weekly"} at ${parts[2]}:${parts[3]}${tz}`;
31
+ return stored;
32
+ }
33
+ /** Which schedules the grant covers, by name and time. */
34
+ export const unattendedCoverWords = (triggers) => `It covers ${triggers.length === 1 ? "this schedule" : "these schedules"} when ${triggers.length === 1 ? "it fires" : "they fire"} on time: ${triggers.map(one => one.words).join("; ")}. Run now, and schedules added or changed later, wait for you.`;
35
+ /** The optional limits. */
36
+ export function unattendedLimitWords(budgetMicrousd, dailyRunCap) {
37
+ return [
38
+ budgetMicrousd === null ? "No dollar cap per attempt." : `Each attempt may spend up to ${dollarWords(budgetMicrousd)}.`,
39
+ dailyRunCap === null ? "No daily limit on runs." : `At most ${dailyRunCap} run${dailyRunCap === 1 ? "" : "s"} a day (UTC); after that, cards wait for you.`,
40
+ ];
41
+ }
42
+ /** What it never covers, and what ends it. */
43
+ export const UNATTENDED_RULE_WORDS = [
44
+ "Plans are approved only when they keep the filed request exactly. Questions and plan changes still wait for you.",
45
+ "Never covered: Person decides steps, pull request merges, publish or deploy scripts, and anything it sends. Cards people add and other triggers wait as usual.",
46
+ "Editing the flow, its schedules, a script they run or a project file it imports, changing its owner, the owner losing access, or protecting the project ends this.",
47
+ "Signing again replaces it: work it approved that hasn't started goes back to waiting for you.",
48
+ ];
49
+ export const unattendedEndWords = (expiry) => `Ends ${untilWords(expiry)}. Revoking it earlier is one click.`;
50
+ export const unattendedEndAfterWords = (days) => `Ends ${days} day${days === 1 ? "" : "s"} after you sign. Revoking it earlier is one click.`;
@@ -0,0 +1,227 @@
1
+ import type { AcceptanceCriterion } from "./contracts/scope.js";
2
+ import { untilWords } from "./flow-unattended-words.js";
3
+ import type { FlowRow, FlowTriggerRow, Store } from "./store.js";
4
+ /** Grant history, run reservations and the last prepared script snapshots shared by UI/worker processes. Additive only. */
5
+ export declare const FLOW_UNATTENDED_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_script_provenance (\n repo TEXT NOT NULL, name TEXT NOT NULL, snapshot TEXT, PRIMARY KEY (repo, name)\n);\nCREATE TABLE IF NOT EXISTS flow_unattended_grant (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n flow INTEGER NOT NULL,\n repo TEXT NOT NULL,\n flow_revision INTEGER NOT NULL,\n terms_json TEXT NOT NULL,\n digest TEXT NOT NULL,\n signed_by TEXT NOT NULL,\n signed_at TEXT NOT NULL,\n absolute_expiry TEXT NOT NULL,\n revoked_at TEXT,\n revoked_by TEXT,\n revoke_reason TEXT\n);\nCREATE UNIQUE INDEX IF NOT EXISTS flow_unattended_live ON flow_unattended_grant (flow) WHERE revoked_at IS NULL;\nCREATE INDEX IF NOT EXISTS flow_unattended_digest ON flow_unattended_grant (digest);\nCREATE TABLE IF NOT EXISTS flow_unattended_run (\n grant_id INTEGER NOT NULL,\n day TEXT NOT NULL,\n task_id TEXT NOT NULL,\n at TEXT NOT NULL,\n PRIMARY KEY (grant_id, task_id)\n);\nCREATE INDEX IF NOT EXISTS flow_unattended_run_day ON flow_unattended_run (grant_id, day);\n";
6
+ /** The fixed acceptance criteria a flow's work zones file with: the same for every card, so a grant can freeze them. */
7
+ export declare const FLOW_ACCEPTANCE: {
8
+ report: AcceptanceCriterion[];
9
+ task: AcceptanceCriterion[];
10
+ };
11
+ /** The most runs a day a grant may allow. */
12
+ export declare const UNATTENDED_MAX_RUNS = 50;
13
+ /** The largest per-attempt budget a grant may name: $100. */
14
+ export declare const UNATTENDED_MAX_BUDGET_MICROUSD = 100000000;
15
+ /** The code a script schedule runs, as signed: the script's version and content digest, and for one that runs a project
16
+ * file, that file's path and, sorted by path, the digest of its bytes and of every project file it statically imports. */
17
+ export type SignedScript = {
18
+ provenance: 2;
19
+ name: string;
20
+ version: number;
21
+ digest: string;
22
+ file: string | null;
23
+ files: {
24
+ path: string;
25
+ digest: string;
26
+ }[];
27
+ };
28
+ /** One schedule a grant covers: which trigger, what sort, the digest of its settings when signed, and its name in words.
29
+ * `script` is there exactly when the kind is "script". */
30
+ export type CoveredTrigger = {
31
+ id: number;
32
+ kind: "schedule" | "script" | "plane-review";
33
+ digest: string;
34
+ words: string;
35
+ script?: SignedScript;
36
+ };
37
+ export type UnattendedTerms = {
38
+ flow: number;
39
+ /** Saving the flow moves its revision: what was signed was this drawing. */
40
+ flowRevision: number;
41
+ repo: string;
42
+ /** The signer, who must stay the flow's owner. */
43
+ owner: string;
44
+ /** The schedules signed for, frozen: any other trigger, or one of these changed, is not covered. */
45
+ triggers: CoveredTrigger[];
46
+ /** Always safe: an escalated posture is never covered. */
47
+ permissions: "safe";
48
+ acceptance: {
49
+ report: AcceptanceCriterion[];
50
+ task: AcceptanceCriterion[];
51
+ };
52
+ perAttemptBudgetMicrousd: number | null;
53
+ /** Filings per UTC day, reserved at filing. */
54
+ dailyRunCap: number | null;
55
+ absoluteExpiry: string;
56
+ };
57
+ export declare const unattendedTermsJson: (terms: UnattendedTerms) => string;
58
+ /** sha256 over a domain-separated canonical encoding, 32 hex like the mode digest. */
59
+ export declare function unattendedDigestOf(terms: UnattendedTerms): string;
60
+ /** Strict rehydration: anything unexpected is null, never a guess. */
61
+ export declare function unattendedTermsFromJson(json: string | null): UnattendedTerms | null;
62
+ export { untilWords };
63
+ /** Every term in words: what the ceremony shows and the password signs. */
64
+ export declare function unattendedWords(terms: UnattendedTerms): string[];
65
+ export type UnattendedGrant = {
66
+ id: number;
67
+ flow: number;
68
+ repo: string;
69
+ terms: UnattendedTerms;
70
+ digest: string;
71
+ signedBy: string;
72
+ signedAt: string;
73
+ absoluteExpiry: string;
74
+ };
75
+ /** SQL: whether the grant whose digest is `digest` still stands at `now` for work in `repo` — live, unexpired, its
76
+ * signer an active approver who can still reach the flow's project and this one, still the owner of the very flow
77
+ * revision signed, and neither project protected. Shared by every dispatch belt. */
78
+ export declare const grantStandsSql: (digest: string, now: string, repo: string) => string;
79
+ /** Why a grant ended, as its row and ledger line say it. A renewal's ledger line is the new signature's. */
80
+ export type UnattendedEnd = "expired" | "revoked" | "renewed" | "schedule changed" | "script changed" | "project protected" | "access changed";
81
+ /**
82
+ * The one road for what ends grants from outside this module (store.ts calls it): a flow's schedule added, paused,
83
+ * resumed or removed; a project's approval rules protecting work; an account's access or standing changing. Every live
84
+ * grant the filter names ends now, durably, with its ledger line, and its queued approvals are withdrawn. Call inside
85
+ * the mutation's transaction. Returns how many ended.
86
+ */
87
+ export declare function endUnattendedGrants(store: Store, filter: {
88
+ flow: number;
89
+ } | {
90
+ repo: string;
91
+ } | {
92
+ signer: string;
93
+ } | {
94
+ script: {
95
+ repo: string;
96
+ name: string;
97
+ };
98
+ }, by: string, reason: Exclude<UnattendedEnd, "expired" | "revoked" | "renewed">, now: Date): number;
99
+ /** The live grant for a flow, or null. One past its expiry is closed here, durably (like activeMode). A grant whose
100
+ * signer is no longer an active approver, or whose flow moved on, covers nothing. */
101
+ export declare function activeUnattendedGrant(store: Store, flowId: number, now: Date): UnattendedGrant | null;
102
+ /** sha256 over a trigger's identity and canonical settings: what a grant freezes for each schedule it covers. */
103
+ export declare function triggerDigestOf(trigger: Pick<FlowTriggerRow, "id" | "flow" | "kind" | "configJson">): string | null;
104
+ /** Asynchronous preparation, never called from filing transactions or page rendering. */
105
+ export declare function scriptProvenanceOf(store: Store, repo: string, name: string, fresh?: boolean): Promise<SignedScript | null>;
106
+ export declare function prepareUnattendedCandidates(store: Store, flowId: number): Promise<void>;
107
+ /** What an on-time script firing ran, as its firing records it: a digest of the code pinned just before and just after
108
+ * the run, when both were the same and were the script row that ran; else null (it can't have run the code signed). */
109
+ export declare function scriptRanDigestOf(before: SignedScript | null, after: SignedScript | null, ran: {
110
+ version: number;
111
+ digest: string;
112
+ }): string | null;
113
+ /** Whether `flowId`'s live grant signed `triggerId`'s script: only then does a firing pin the code it runs. */
114
+ export declare function grantSignsScriptOf(store: Store, flowId: number, triggerId: number, now: Date): boolean;
115
+ /** A schedule a grant could cover, and whether it is covered only when the owner picks it (`optional`, with why). */
116
+ export type UnattendedCandidate = CoveredTrigger & {
117
+ optional: string | null;
118
+ };
119
+ /**
120
+ * The flow's active schedules a grant could cover. A plain schedule makes its own card from its own words, so it is
121
+ * covered by default. A script's items and a plane review's problems are outside content (issues, mail, what a run
122
+ * saw), so they are covered only when the owner picks them. A standing order files under its own signed terms, and a
123
+ * paused or unreadable schedule, or a script whose code can't be read safely, is never offered.
124
+ */
125
+ export declare function unattendedCandidates(store: Store, flowId: number): UnattendedCandidate[];
126
+ /** A digest of the schedules the page offered, so a signature never covers one the person didn't read. */
127
+ export declare const candidatesDigestOf: (candidates: readonly UnattendedCandidate[]) => string;
128
+ /** Why `signer` can't let this flow run on its own, or null: what the flow page shows instead of the form. */
129
+ export declare function unattendedSignProblem(store: Store, flow: FlowRow, signer: string): string | null;
130
+ /** `include`: the schedules (trigger ids) the owner picked beyond the plain ones covered by default. */
131
+ export type UnattendedAsk = {
132
+ days: number;
133
+ budgetMicrousd: number | null;
134
+ dailyRunCap: number | null;
135
+ include?: number[];
136
+ };
137
+ /** Read what a person typed: whole days 1–MODE_MAX_DAYS, an optional dollar budget, an optional runs-a-day cap. */
138
+ export declare function parseUnattendedAsk(input: {
139
+ days: unknown;
140
+ budget?: unknown;
141
+ runs?: unknown;
142
+ include?: unknown;
143
+ }): {
144
+ ok: true;
145
+ ask: UnattendedAsk;
146
+ } | {
147
+ ok: false;
148
+ message: string;
149
+ };
150
+ /** The exact terms a signature would cover now, for these schedules. */
151
+ export declare function unattendedTermsFor(flow: FlowRow, owner: string, ask: UnattendedAsk, triggers: readonly CoveredTrigger[], now: Date): UnattendedTerms;
152
+ /** Sign (or renew) the grant. The caller has already checked the signer's password; every predicate is proved again
153
+ * here against the flow as it now stands: files asynchronously first, then database facts inside the transaction. `sawRevision` and `sawSchedules` (candidatesDigestOf),
154
+ * when given, are the flow and the schedules the person read. Renewing ends the old grant and withdraws what it sealed. */
155
+ export declare function signUnattended(store: Store, input: {
156
+ flow: number;
157
+ signer: string;
158
+ ask: UnattendedAsk;
159
+ sawRevision?: number;
160
+ sawSchedules?: string;
161
+ }, now: Date): Promise<{
162
+ ok: true;
163
+ grant: UnattendedGrant;
164
+ } | {
165
+ ok: false;
166
+ message: string;
167
+ }>;
168
+ /** One click for any approver on the project: lowering authority needs no password. False when there was none. */
169
+ export declare function revokeUnattended(store: Store, flowId: number, by: string, now: Date): boolean;
170
+ /**
171
+ * Whether a card came from one of the schedules this grant signed for, fired on time after it was signed: the firing
172
+ * names the card and was the clock's (never Run now), its trigger is still active with the very settings signed, and no
173
+ * person has moved the card or sent it back since. Anything else is a person's card, or one the grant never read.
174
+ * A script schedule's firing must have run the very code signed (scriptRanDigestOf), and it must still be that code;
175
+ * when it isn't, the grant ends here (unattendedCoverageFor).
176
+ */
177
+ export declare function cardFromSignedSchedule(store: Store, grant: UnattendedGrant, cardId: number, requireScript?: boolean): boolean;
178
+ /** How many runs a grant has filed on `now`'s UTC day. */
179
+ export declare function unattendedRunsToday(store: Store, grantId: number, now: Date): number;
180
+ /** Whether the grant covers this card's next filing (in `repo`) now; call inside the filing transaction. */
181
+ export declare function unattendedCoverageFor(store: Store, flow: FlowRow, card: {
182
+ id: number;
183
+ }, repo: string, now: Date): UnattendedGrant | null;
184
+ /** Count one run against the grant's day, in one statement that admits it only under the cap (so two filings racing
185
+ * on two connections can't both take the last run). False when the day is full; then nothing is sealed. */
186
+ export declare function reserveUnattendedRun(store: Store, grant: UnattendedGrant, taskId: string, now: Date): boolean;
187
+ /** Give back a run reserved for a filing that then couldn't be sealed. */
188
+ export declare function releaseUnattendedRun(store: Store, grant: UnattendedGrant, taskId: string): void;
189
+ /** The grant that sealed (or pre-authorized) a task still stands for it: the same live digest, the flow unchanged and
190
+ * still owned by the signer, the signer able to approve in the task's project, its scripts' code unchanged (else it
191
+ * ends here), and the work not protected. */
192
+ export declare function unattendedGrantStands(store: Store, digest: string, signer: string, taskRepo: string | null, touches: readonly string[], now: Date): boolean;
193
+ /** The flow page's and CLI's view of the grant: the live state, and whether (and why not) this viewer can sign. */
194
+ export type UnattendedView = {
195
+ active: {
196
+ until: string;
197
+ untilWords: string;
198
+ signedBy: string;
199
+ budgetMicrousd: number | null;
200
+ dailyRunCap: number | null;
201
+ runsToday: number;
202
+ words: string[];
203
+ covers: string[];
204
+ } | null;
205
+ canSign: boolean;
206
+ problem: string | null;
207
+ maxDays: number;
208
+ /** Whether the flow has a schedule, the only thing a grant covers. */
209
+ scheduled: boolean;
210
+ /** What a signature covers besides the limits and schedules the form chooses (unattendedLimitWords, unattendedCoverWords,
211
+ * unattendedEndAfterWords). */
212
+ terms: {
213
+ head: string[];
214
+ rules: string[];
215
+ };
216
+ /** The schedules it could cover (optional ones only when picked), and their digest, which the form sends back. */
217
+ schedules: {
218
+ id: number;
219
+ words: string;
220
+ optional: string | null;
221
+ }[];
222
+ schedulesDigest: string;
223
+ };
224
+ export declare function unattendedViewOf(store: Store, flow: FlowRow, viewer: {
225
+ name: string;
226
+ approver: boolean;
227
+ }, now: Date): UnattendedView;