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
@@ -0,0 +1,776 @@
1
+ /**
2
+ * "Runs on its own": a per-flow, password-signed, expiring grant that lets the
3
+ * cards a flow's OWN SCHEDULE adds file work that is approved without a person
4
+ * reading each one. The flow's owner signs it (the mode ceremony and expiry
5
+ * rules: at most MODE_MAX_DAYS); any approver revokes it with one click.
6
+ *
7
+ * What it covers is deliberately narrow: the build and research tasks those
8
+ * cards file, sealed with safe permissions, the flow's fixed acceptance
9
+ * criteria and the signed budget and daily run cap; their plans approve only
10
+ * when unchanged (plan-auto.ts). Everything outward stays a person's: Person
11
+ * decides zones, pull request merges, publish and deploy scripts, and sends.
12
+ * Protected projects and paths are never covered. The grant names the very
13
+ * schedules it covers (each one's settings digested into the signature), and
14
+ * only their on-time firings count: Run now, a card a person moved, a schedule
15
+ * added, changed, paused or removed, editing the flow, changing its owner, the
16
+ * owner losing access and protecting the project all end or fall outside it. A script schedule also pins the script's
17
+ * code (its version and content digest), the bytes of any project file it runs and, for a Node file, of every project
18
+ * file it statically imports (the module graph Node resolves from it; an import that doesn't resolve can't be signed).
19
+ * Imports computed at run time aren't knowable and are outside the claim. Saving the script ends the grant, and a file
20
+ * that changed or can't be read ends it after asynchronous refresh, when the next card is sealed or its work is authorized. Each on-time firing
21
+ * pins the code just before and just after it runs and records what it ran: its cards are covered only when that was
22
+ * the code signed, so swapping a file for one run and restoring it before filing leaves the cards to a person.
23
+ *
24
+ * This module owns the terms, digest, words and transactions; the flow engine
25
+ * asks for coverage inside its filing transaction, and plan-auto.ts seals.
26
+ */
27
+ import { spawn } from "node:child_process";
28
+ import { createHash } from "node:crypto";
29
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
30
+ import { createRequire } from "node:module";
31
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
32
+ import { isProtectedWork } from "./approval-policy.js";
33
+ import { dollarWords as dollars, scheduleWords, UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedEndWords, unattendedHeadWords, unattendedLimitWords, untilWords } from "./flow-unattended-words.js";
34
+ import { MODE_MAX_DAYS } from "./modes.js";
35
+ import { accountReachesSql } from "./project-access.js";
36
+ /** Grant history, run reservations and the last prepared script snapshots shared by UI/worker processes. Additive only. */
37
+ export const FLOW_UNATTENDED_SCHEMA = `
38
+ CREATE TABLE IF NOT EXISTS flow_script_provenance (
39
+ repo TEXT NOT NULL, name TEXT NOT NULL, snapshot TEXT, PRIMARY KEY (repo, name)
40
+ );
41
+ CREATE TABLE IF NOT EXISTS flow_unattended_grant (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ flow INTEGER NOT NULL,
44
+ repo TEXT NOT NULL,
45
+ flow_revision INTEGER NOT NULL,
46
+ terms_json TEXT NOT NULL,
47
+ digest TEXT NOT NULL,
48
+ signed_by TEXT NOT NULL,
49
+ signed_at TEXT NOT NULL,
50
+ absolute_expiry TEXT NOT NULL,
51
+ revoked_at TEXT,
52
+ revoked_by TEXT,
53
+ revoke_reason TEXT
54
+ );
55
+ CREATE UNIQUE INDEX IF NOT EXISTS flow_unattended_live ON flow_unattended_grant (flow) WHERE revoked_at IS NULL;
56
+ CREATE INDEX IF NOT EXISTS flow_unattended_digest ON flow_unattended_grant (digest);
57
+ CREATE TABLE IF NOT EXISTS flow_unattended_run (
58
+ grant_id INTEGER NOT NULL,
59
+ day TEXT NOT NULL,
60
+ task_id TEXT NOT NULL,
61
+ at TEXT NOT NULL,
62
+ PRIMARY KEY (grant_id, task_id)
63
+ );
64
+ CREATE INDEX IF NOT EXISTS flow_unattended_run_day ON flow_unattended_run (grant_id, day);
65
+ `;
66
+ /** The fixed acceptance criteria a flow's work zones file with: the same for every card, so a grant can freeze them. */
67
+ export const FLOW_ACCEPTANCE = {
68
+ report: [{ id: "c1", statement: "The report answers the request, citing where in the repository each point comes from", how: null, evidence: ["manual-review"] }],
69
+ task: [{ id: "c1", statement: "The requested change is made and the project's existing checks still pass", how: null, evidence: ["manual-review"] }],
70
+ };
71
+ /** The most runs a day a grant may allow. */
72
+ export const UNATTENDED_MAX_RUNS = 50;
73
+ /** The largest per-attempt budget a grant may name: $100. */
74
+ export const UNATTENDED_MAX_BUDGET_MICROUSD = 100_000_000;
75
+ function canonicalJson(value) {
76
+ if (Array.isArray(value))
77
+ return `[${value.map(canonicalJson).join(",")}]`;
78
+ if (value !== null && typeof value === "object") {
79
+ const entries = Object.entries(value)
80
+ .filter(([, v]) => v !== undefined)
81
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
82
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
83
+ return `{${entries.join(",")}}`;
84
+ }
85
+ return JSON.stringify(value);
86
+ }
87
+ export const unattendedTermsJson = (terms) => canonicalJson(terms);
88
+ /** sha256 over a domain-separated canonical encoding, 32 hex like the mode digest. */
89
+ export function unattendedDigestOf(terms) {
90
+ return createHash("sha256").update(`standing-orders:flow-unattended:${unattendedTermsJson(terms)}`, "utf8").digest("hex").slice(0, 32);
91
+ }
92
+ function criteria(value) {
93
+ if (!Array.isArray(value) || value.length === 0)
94
+ return null;
95
+ const out = [];
96
+ for (const one of value) {
97
+ if (one === null || typeof one !== "object")
98
+ return null;
99
+ const c = one;
100
+ if (typeof c["id"] !== "string" || typeof c["statement"] !== "string" || c["statement"].trim() === "" || !(c["how"] === null || typeof c["how"] === "string") ||
101
+ !Array.isArray(c["evidence"]) || !c["evidence"].every(e => typeof e === "string"))
102
+ return null;
103
+ out.push({ id: c["id"], statement: c["statement"], how: c["how"], evidence: [...c["evidence"]] });
104
+ }
105
+ return out;
106
+ }
107
+ const hex32 = (v) => typeof v === "string" && /^[0-9a-f]{32}$/.test(v);
108
+ /** Earlier script shapes become inactive at upgrade: the owner must sign the stricter provenance terms again. */
109
+ function signedScript(value) {
110
+ if (value === null || typeof value !== "object" || Array.isArray(value))
111
+ return null;
112
+ const s = value, file = s["file"];
113
+ if (s["provenance"] !== 2 || typeof s["name"] !== "string" || s["name"] === "" || !(typeof s["version"] === "number" && Number.isSafeInteger(s["version"]) && s["version"] > 0) || !hex32(s["digest"]))
114
+ return null;
115
+ if (!(file === null || (typeof file === "string" && file !== "")) || !Array.isArray(s["files"]))
116
+ return null;
117
+ const files = [];
118
+ for (const one of s["files"]) {
119
+ if (one === null || typeof one !== "object" || Array.isArray(one))
120
+ return null;
121
+ const f = one;
122
+ if (typeof f["path"] !== "string" || f["path"] === "" || !hex32(f["digest"]) || (files.length > 0 && files.at(-1).path >= f["path"]))
123
+ return null;
124
+ files.push({ path: f["path"], digest: f["digest"] });
125
+ }
126
+ if (file === null ? files.length > 0 : !files.some(one => one.path === file))
127
+ return null;
128
+ return { provenance: 2, name: s["name"], version: s["version"], digest: s["digest"], file, files };
129
+ }
130
+ function coveredTriggers(value) {
131
+ if (!Array.isArray(value) || value.length === 0)
132
+ return null;
133
+ const out = [];
134
+ for (const one of value) {
135
+ if (one === null || typeof one !== "object")
136
+ return null;
137
+ const t = one;
138
+ if (!(typeof t["id"] === "number" && Number.isSafeInteger(t["id"]) && t["id"] > 0) || !(t["kind"] === "schedule" || t["kind"] === "script" || t["kind"] === "plane-review") ||
139
+ !hex32(t["digest"]) || typeof t["words"] !== "string")
140
+ return null;
141
+ // A script schedule signed without its code (or anything else carrying one) is unreadable, never a guess.
142
+ if (t["kind"] !== "script") {
143
+ if (t["script"] !== undefined)
144
+ return null;
145
+ out.push({ id: t["id"], kind: t["kind"], digest: t["digest"], words: t["words"] });
146
+ continue;
147
+ }
148
+ const script = signedScript(t["script"]);
149
+ if (script === null)
150
+ return null;
151
+ out.push({ id: t["id"], kind: "script", digest: t["digest"], words: t["words"], script });
152
+ }
153
+ return out;
154
+ }
155
+ /** Strict rehydration: anything unexpected is null, never a guess. */
156
+ export function unattendedTermsFromJson(json) {
157
+ if (json === null)
158
+ return null;
159
+ let t;
160
+ try {
161
+ const parsed = JSON.parse(json);
162
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
163
+ return null;
164
+ t = parsed;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ const int = (v) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
170
+ const acceptance = t["acceptance"];
171
+ const report = acceptance != null && typeof acceptance === "object" ? criteria(acceptance["report"]) : null;
172
+ const task = acceptance != null && typeof acceptance === "object" ? criteria(acceptance["task"]) : null;
173
+ const budget = t["perAttemptBudgetMicrousd"], cap = t["dailyRunCap"];
174
+ const triggers = coveredTriggers(t["triggers"]);
175
+ if (!int(t["flow"]) || !(typeof t["flowRevision"] === "number" && Number.isSafeInteger(t["flowRevision"])) || typeof t["repo"] !== "string" || typeof t["owner"] !== "string" ||
176
+ triggers === null || t["permissions"] !== "safe" || report === null || task === null || !(budget === null || int(budget)) || !(cap === null || int(cap)) ||
177
+ typeof t["absoluteExpiry"] !== "string" || Number.isNaN(Date.parse(t["absoluteExpiry"])))
178
+ return null;
179
+ return { flow: t["flow"], flowRevision: t["flowRevision"], repo: t["repo"], owner: t["owner"], triggers, permissions: "safe", acceptance: { report, task },
180
+ perAttemptBudgetMicrousd: budget, dailyRunCap: cap, absoluteExpiry: t["absoluteExpiry"] };
181
+ }
182
+ export { untilWords };
183
+ /** Every term in words: what the ceremony shows and the password signs. */
184
+ export function unattendedWords(terms) {
185
+ return [...unattendedHeadWords(terms.owner, terms.acceptance), unattendedCoverWords(terms.triggers), ...unattendedLimitWords(terms.perAttemptBudgetMicrousd, terms.dailyRunCap), ...UNATTENDED_RULE_WORDS, unattendedEndWords(terms.absoluteExpiry)];
186
+ }
187
+ /** SQL: whether neither project's approval rules protect anything (flow work names no paths, so any protected path
188
+ * could be reached). An unreadable path list counts as protected. */
189
+ const unprotectedSql = (repos) => `NOT EXISTS (SELECT 1 FROM approval_policy ug_rules WHERE ug_rules.repo IN (${repos})
190
+ AND (ug_rules.protect_project = 1 OR NOT json_valid(ug_rules.protected_paths) OR json_array_length(ug_rules.protected_paths) > 0))`;
191
+ /** SQL: whether the grant whose digest is `digest` still stands at `now` for work in `repo` — live, unexpired, its
192
+ * signer an active approver who can still reach the flow's project and this one, still the owner of the very flow
193
+ * revision signed, and neither project protected. Shared by every dispatch belt. */
194
+ export const grantStandsSql = (digest, now, repo) => `EXISTS (SELECT 1 FROM flow_unattended_grant ug
195
+ JOIN approver ug_signer ON ug_signer.name = ug.signed_by
196
+ JOIN flow ug_flow ON ug_flow.id = ug.flow
197
+ WHERE ug.digest = ${digest} AND ug.revoked_at IS NULL AND ug.absolute_expiry > ${now}
198
+ AND ug_signer.revoked_at IS NULL AND ug_signer.role = 'approver'
199
+ AND ${accountReachesSql("ug_signer", "ug.repo")} AND ${accountReachesSql("ug_signer", repo)}
200
+ AND ug_flow.state = 'active' AND ug_flow.revision = ug.flow_revision AND COALESCE(ug_flow.owner, ug_flow.created_by) = ug.signed_by
201
+ AND ${unprotectedSql(`ug.repo, ${repo}`)})`;
202
+ function readGrant(row) {
203
+ const terms = unattendedTermsFromJson(String(row["terms_json"]));
204
+ if (terms === null || unattendedDigestOf(terms) !== String(row["digest"]))
205
+ return null;
206
+ return { id: Number(row["id"]), flow: Number(row["flow"]), repo: String(row["repo"]), terms, digest: String(row["digest"]), signedBy: String(row["signed_by"]), signedAt: String(row["signed_at"]), absoluteExpiry: String(row["absolute_expiry"]) };
207
+ }
208
+ /** Close a grant: the row, its ledger line, and the undispatched work it sealed or pre-authorized falls back to a person. */
209
+ function closeGrant(store, row, by, reason, now) {
210
+ store.handle.prepare("UPDATE flow_unattended_grant SET revoked_at = ?, revoked_by = ?, revoke_reason = ? WHERE id = ? AND revoked_at IS NULL").run(now.toISOString(), by, reason, Number(row["id"]));
211
+ if (reason !== "renewed") {
212
+ store.recordAction({ at: now.toISOString(), actor: by, repo: String(row["repo"]), taskId: null, runId: null, source: "policy",
213
+ action: `flow stopped running on its own: ${reason}`, outcome: reason === "expired" || reason === "revoked" ? reason : "ended", detail: `flow #${Number(row["flow"])}` });
214
+ }
215
+ store.withdrawGrantApprovals(String(row["digest"]), now);
216
+ }
217
+ /**
218
+ * The one road for what ends grants from outside this module (store.ts calls it): a flow's schedule added, paused,
219
+ * resumed or removed; a project's approval rules protecting work; an account's access or standing changing. Every live
220
+ * grant the filter names ends now, durably, with its ledger line, and its queued approvals are withdrawn. Call inside
221
+ * the mutation's transaction. Returns how many ended.
222
+ */
223
+ export function endUnattendedGrants(store, filter, by, reason, now) {
224
+ const rows = "flow" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").all(filter.flow)
225
+ : "signer" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE signed_by = ? AND revoked_at IS NULL").all(filter.signer)
226
+ // A script saved or removed: the grants in its project that signed a schedule running it (an unreadable one too, to be safe).
227
+ : "script" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE repo = ? AND revoked_at IS NULL").all(filter.script.repo).filter(row => {
228
+ const terms = unattendedTermsFromJson(String(row["terms_json"]));
229
+ return terms === null || terms.triggers.some(one => one.script?.name === filter.script.name);
230
+ })
231
+ // A project's rules reach the flows in it and the flows whose build zones work in it.
232
+ : store.handle.prepare(`SELECT ug.* FROM flow_unattended_grant ug JOIN flow f ON f.id = ug.flow WHERE ug.revoked_at IS NULL
233
+ AND (ug.repo = ? OR (json_valid(f.definition_json) AND EXISTS (SELECT 1 FROM json_each(f.definition_json, '$.stages') s WHERE json_extract(s.value, '$.repo') = ?)))`).all(filter.repo, filter.repo);
234
+ for (const row of rows)
235
+ closeGrant(store, row, by, reason, now);
236
+ return rows.length;
237
+ }
238
+ /** The live grant for a flow, or null. One past its expiry is closed here, durably (like activeMode). A grant whose
239
+ * signer is no longer an active approver, or whose flow moved on, covers nothing. */
240
+ export function activeUnattendedGrant(store, flowId, now) {
241
+ const row = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").get(flowId);
242
+ if (row === undefined)
243
+ return null;
244
+ if (Date.parse(String(row["absolute_expiry"])) <= now.getTime()) {
245
+ store.transact(() => closeGrant(store, row, "the-clock", "expired", now));
246
+ return null;
247
+ }
248
+ return readGrant(row);
249
+ }
250
+ /** Why the grant can't cover this flow right now (the same checks signing makes), or null. */
251
+ function coverageProblem(store, flow, owner, workRepos) {
252
+ if (flow.state !== "active")
253
+ return "This flow is archived.";
254
+ if (flow.owner !== owner)
255
+ return `Only ${flow.owner}, who owns this flow, can let it run on its own.`;
256
+ const account = store.accountOf(owner);
257
+ if (account === null || account.revokedAt !== null || account.role !== "approver")
258
+ return `${owner} isn't an active approver.`;
259
+ for (const repo of new Set([flow.repo, ...workRepos])) {
260
+ if (!store.accountCanAccess(owner, repo))
261
+ return `${owner} can't approve work in ${repo}.`;
262
+ const rules = store.approvalRules(repo);
263
+ // Flow work declares no paths, so any protected path could be reached: protected work is always a person's.
264
+ if (isProtectedWork(rules, []))
265
+ return rules.protectProject ? "This project is protected: a person approves its work." : "This project protects some paths, and flow work could reach them: a person approves it.";
266
+ }
267
+ return null;
268
+ }
269
+ /** The projects a flow's work zones file in (a build zone may name another). */
270
+ function workReposOf(flow) {
271
+ try {
272
+ const drawn = JSON.parse(flow.definitionJson);
273
+ return (drawn.stages ?? []).filter(one => one.kind === "task" && typeof one.repo === "string").map(one => one.repo);
274
+ }
275
+ catch {
276
+ return [];
277
+ }
278
+ }
279
+ /** sha256 over a trigger's identity and canonical settings: what a grant freezes for each schedule it covers. */
280
+ export function triggerDigestOf(trigger) {
281
+ let config;
282
+ try {
283
+ config = JSON.parse(trigger.configJson);
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ return createHash("sha256").update(`standing-orders:flow-unattended-trigger:${trigger.id}:${trigger.flow}:${trigger.kind}:${canonicalJson(config)}`, "utf8").digest("hex").slice(0, 32);
289
+ }
290
+ const bytesDigest = (bytes) => createHash("sha256").update("standing-orders:flow-unattended-file:").update(bytes).digest("hex").slice(0, 32);
291
+ /** The most project files one script's code may span; a bigger module graph can't be signed. */
292
+ const MAX_SCRIPT_FILES = 500;
293
+ /** Files whose static imports are read: JavaScript and TypeScript sources. Others (JSON, addons) are pinned as bytes. */
294
+ const SOURCE_FILE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
295
+ let compiler = null;
296
+ const typescript = () => compiler ??= createRequire(import.meta.url)("typescript-parser");
297
+ /** Resolves [specifier, importing file] pairs as the Node that runs scripts does, without loading either: as `import`
298
+ * and as `require` (a .js file's mode can depend on its syntax). Each answer is a path, "builtin", or null. */
299
+ const NODE_RESOLVER = `import { createRequire, isBuiltin } from "node:module";
300
+ import { readFileSync } from "node:fs";
301
+ import { fileURLToPath, pathToFileURL } from "node:url";
302
+ const tried = f => { try { return f(); } catch { return null; } };
303
+ const named = to => isBuiltin(to) ? "builtin" : to.startsWith("file:") ? fileURLToPath(to) : to.startsWith("/") ? to : null;
304
+ process.stdout.write(JSON.stringify(JSON.parse(readFileSync(0, "utf8")).map(([spec, from]) => ({
305
+ esm: tried(() => named(import.meta.resolve(spec, pathToFileURL(from).href))),
306
+ cjs: tried(() => named(createRequire(from).resolve(spec))) }))));`;
307
+ async function resolveAsNode(pairs) {
308
+ return new Promise(resolve => {
309
+ const child = spawn(process.execPath, ["--preserve-symlinks", "--experimental-import-meta-resolve", "--no-warnings", "--input-type=module", "-e", NODE_RESOLVER], { stdio: ["pipe", "pipe", "ignore"] });
310
+ let output = "";
311
+ child.stdout.setEncoding("utf8");
312
+ child.stdout.on("data", (part) => { output += part; if (output.length > 16 * 1024 * 1024)
313
+ child.kill(); });
314
+ child.on("error", () => resolve(null));
315
+ child.stdin.on("error", () => { });
316
+ child.on("close", code => {
317
+ if (code !== 0)
318
+ return resolve(null);
319
+ try {
320
+ const parsed = JSON.parse(output);
321
+ resolve(Array.isArray(parsed) && parsed.length === pairs.length ? parsed : null);
322
+ }
323
+ catch {
324
+ resolve(null);
325
+ }
326
+ });
327
+ child.stdin.end(JSON.stringify(pairs));
328
+ });
329
+ }
330
+ const graphCache = new Map();
331
+ const fileIdentity = (value) => [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
332
+ async function pathIdentity(path) {
333
+ try {
334
+ return fileIdentity(await lstat(path, { bigint: true }));
335
+ }
336
+ catch {
337
+ return "missing";
338
+ }
339
+ }
340
+ const graphNodes = new Map();
341
+ function lastScript(store, repo, name) {
342
+ const row = store.flowScript(repo, name);
343
+ if (row === null || row.held !== null)
344
+ return null;
345
+ if (row.file === null)
346
+ return { provenance: 2, name, version: row.version, digest: row.digest, file: null, files: [] };
347
+ const raw = store.handle.prepare("SELECT snapshot FROM flow_script_provenance WHERE repo = ? AND name = ?").get(repo, name)?.["snapshot"];
348
+ try {
349
+ const saved = typeof raw === "string" ? signedScript(JSON.parse(raw)) : null;
350
+ return saved?.version === row.version && saved.digest === row.digest ? saved : null;
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ const inside = (base, full) => { const path = relative(base, full); return path !== "" && path !== ".." && !path.startsWith("../") && !isAbsolute(path) ? path : null; };
357
+ /** Resolve only literal dependencies, without evaluating the script. Computed imports remain outside the claim. */
358
+ async function nodeModuleGraphOf(base, entry, fresh) {
359
+ const cached = graphCache.get(entry);
360
+ if (!fresh && cached && (await Promise.all([...cached.watched].map(async ([path, identity]) => await pathIdentity(path) === identity))).every(Boolean))
361
+ return cached.files;
362
+ const watched = new Map();
363
+ const watch = async (path) => { if (!watched.has(path))
364
+ watched.set(path, await pathIdentity(path)); };
365
+ const watchResolution = async (file) => {
366
+ await watch(file);
367
+ for (let folder = dirname(file); folder === base || inside(base, folder) !== null; folder = dirname(folder)) {
368
+ await watch(folder);
369
+ await watch(join(folder, "package.json"));
370
+ await watch(join(folder, "node_modules"));
371
+ }
372
+ };
373
+ const pinned = new Map();
374
+ for (let layer = [entry]; layer.length > 0;) {
375
+ const pairs = [];
376
+ for (const file of layer) {
377
+ await watchResolution(file);
378
+ const metadata = await stat(file, { bigint: true });
379
+ const identity = fileIdentity(metadata);
380
+ let node = graphNodes.get(file);
381
+ if (fresh || node?.identity !== identity) {
382
+ const bytes = await readFile(file);
383
+ const imports = SOURCE_FILE.test(file) ? typescript().preProcessFile(bytes.toString("utf8"), true, true).importedFiles.map(one => one.fileName) : [];
384
+ if (SOURCE_FILE.test(file)) {
385
+ const source = typescript().createSourceFile(file, bytes.toString("utf8"), typescript().ScriptTarget.Latest, true);
386
+ const visit = (child) => {
387
+ if (typescript().isCallExpression(child) && typescript().isCallExpression(child.expression) &&
388
+ /(?:^|\.)createRequire$/.test(child.expression.expression.getText(source))) {
389
+ const arg = child.arguments[0];
390
+ if (arg && typescript().isStringLiteralLike(arg))
391
+ imports.push(arg.text);
392
+ }
393
+ typescript().forEachChild(child, visit);
394
+ };
395
+ visit(source);
396
+ }
397
+ node = { identity, digest: bytesDigest(bytes), imports };
398
+ graphNodes.delete(file);
399
+ graphNodes.set(file, node);
400
+ if (graphNodes.size > MAX_SCRIPT_FILES)
401
+ graphNodes.delete(graphNodes.keys().next().value);
402
+ }
403
+ pinned.set(file, node.digest);
404
+ for (const spec of node.imports)
405
+ pairs.push([spec, file]);
406
+ }
407
+ if (pinned.size > MAX_SCRIPT_FILES)
408
+ return null;
409
+ const answers = pairs.length === 0 ? [] : await resolveAsNode(pairs);
410
+ if (answers === null)
411
+ return null;
412
+ const next = new Set();
413
+ for (const [index, { esm, cjs }] of answers.entries()) {
414
+ const [spec, from] = pairs[index];
415
+ // Node may have already canonicalized a relative symlink. Check its lexical target too.
416
+ if (spec.startsWith(".")) {
417
+ const lexical = resolve(dirname(from), spec);
418
+ await watchResolution(lexical);
419
+ try {
420
+ if (inside(base, lexical) !== null && inside(base, await realpath(lexical)) === null)
421
+ return null;
422
+ }
423
+ catch { /* resolver below decides */ }
424
+ }
425
+ let found = false;
426
+ for (const one of new Set([esm, cjs])) {
427
+ if (one === "builtin") {
428
+ found = true;
429
+ continue;
430
+ }
431
+ if (one === null)
432
+ continue;
433
+ await watchResolution(one);
434
+ let full;
435
+ try {
436
+ if (!(await stat(one)).isFile())
437
+ continue;
438
+ full = await realpath(one);
439
+ }
440
+ catch {
441
+ continue;
442
+ }
443
+ found = true;
444
+ // A project dependency that escapes through a symlink is unsafe, never silently omitted.
445
+ if (inside(base, full) === null) {
446
+ if (inside(base, one) !== null)
447
+ return null;
448
+ continue;
449
+ }
450
+ if (!pinned.has(full))
451
+ next.add(full);
452
+ }
453
+ if (!found)
454
+ return null;
455
+ }
456
+ layer = [...next];
457
+ }
458
+ const files = [...pinned].map(([full, digest]) => ({ path: inside(base, full), digest })).sort((a, b) => a.path < b.path ? -1 : 1);
459
+ if (!(await Promise.all([...watched].map(async ([path, identity]) => await pathIdentity(path) === identity))).every(Boolean))
460
+ return null;
461
+ graphCache.delete(entry);
462
+ graphCache.set(entry, { files, watched });
463
+ if (graphCache.size > 32)
464
+ graphCache.delete(graphCache.keys().next().value);
465
+ return files;
466
+ }
467
+ /** Asynchronous preparation, never called from filing transactions or page rendering. */
468
+ export async function scriptProvenanceOf(store, repo, name, fresh = true) {
469
+ const script = store.flowScript(repo, name);
470
+ let result = null;
471
+ try {
472
+ if (script !== null && script.held === null) {
473
+ if (script.file === null)
474
+ result = { provenance: 2, name, version: script.version, digest: script.digest, file: null, files: [] };
475
+ else if (!isAbsolute(script.file) && !script.file.split(/[\\/]/).includes("..")) {
476
+ const base = await realpath(repo), lexical = join(base, script.file), full = await realpath(lexical);
477
+ if (!(await lstat(lexical)).isSymbolicLink() && full === lexical && inside(base, full) !== null && (await stat(full)).isFile()) {
478
+ const files = script.language === "node" ? await nodeModuleGraphOf(base, full, fresh) : [{ path: script.file, digest: bytesDigest(await readFile(full)) }];
479
+ if (files !== null)
480
+ result = { provenance: 2, name, version: script.version, digest: script.digest, file: script.file, files };
481
+ }
482
+ }
483
+ }
484
+ }
485
+ catch {
486
+ result = null;
487
+ }
488
+ store.handle.prepare("INSERT INTO flow_script_provenance (repo, name, snapshot) VALUES (?, ?, ?) ON CONFLICT(repo, name) DO UPDATE SET snapshot = excluded.snapshot").run(repo, name, result === null ? null : JSON.stringify(result));
489
+ return result;
490
+ }
491
+ export async function prepareUnattendedCandidates(store, flowId) {
492
+ const flow = store.getFlow(flowId);
493
+ if (flow === null)
494
+ return;
495
+ for (const trigger of store.flowTriggers(flowId)) {
496
+ if (trigger.kind !== "schedule" || trigger.state !== "active")
497
+ continue;
498
+ try {
499
+ const config = JSON.parse(trigger.configJson);
500
+ if (typeof config.script === "string")
501
+ await scriptProvenanceOf(store, flow.repo, config.script, false);
502
+ }
503
+ catch { /* invalid schedule isn't offered */ }
504
+ }
505
+ }
506
+ /** What an on-time script firing ran, as its firing records it: a digest of the code pinned just before and just after
507
+ * the run, when both were the same and were the script row that ran; else null (it can't have run the code signed). */
508
+ export function scriptRanDigestOf(before, after, ran) {
509
+ if (before === null || after === null || canonicalJson(before) !== canonicalJson(after) || before.version !== ran.version || before.digest !== ran.digest)
510
+ return null;
511
+ return ranDigestOf(before);
512
+ }
513
+ const ranDigestOf = (script) => createHash("sha256").update(`standing-orders:flow-unattended-script-ran:${canonicalJson(script)}`, "utf8").digest("hex").slice(0, 32);
514
+ /** Whether `flowId`'s live grant signed `triggerId`'s script: only then does a firing pin the code it runs. */
515
+ export function grantSignsScriptOf(store, flowId, triggerId, now) {
516
+ const trigger = store.getFlowTrigger(triggerId), flow = store.getFlow(flowId);
517
+ const grant = activeUnattendedGrant(store, flowId, now);
518
+ return trigger !== null && trigger.state === "active" && flow !== null && grant !== null &&
519
+ flow.revision === grant.terms.flowRevision && coverageProblem(store, flow, grant.signedBy, [flow.repo]) === null &&
520
+ grant.terms.triggers.some(one => one.id === triggerId && one.kind === "script" && one.digest === triggerDigestOf(trigger));
521
+ }
522
+ /** Whether each script schedule the grant signed would still run the very code signed. */
523
+ function scriptsUnchanged(store, terms) {
524
+ return terms.triggers.every(one => one.kind !== "script" ||
525
+ (one.script !== undefined && canonicalJson(lastScript(store, terms.repo, one.script.name)) === canonicalJson(one.script)));
526
+ }
527
+ /** A grant whose script or project file drifted ends now, durably, and its queued approvals are withdrawn. True when it did. */
528
+ function endIfScriptsChanged(store, grant, now) {
529
+ if (scriptsUnchanged(store, grant.terms))
530
+ return false;
531
+ store.transact(() => {
532
+ const row = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE id = ? AND revoked_at IS NULL").get(grant.id);
533
+ if (row !== undefined)
534
+ closeGrant(store, row, "flow", "script changed", now);
535
+ });
536
+ return true;
537
+ }
538
+ /**
539
+ * The flow's active schedules a grant could cover. A plain schedule makes its own card from its own words, so it is
540
+ * covered by default. A script's items and a plane review's problems are outside content (issues, mail, what a run
541
+ * saw), so they are covered only when the owner picks them. A standing order files under its own signed terms, and a
542
+ * paused or unreadable schedule, or a script whose code can't be read safely, is never offered.
543
+ */
544
+ export function unattendedCandidates(store, flowId) {
545
+ const out = [];
546
+ const flowRepo = store.getFlow(flowId)?.repo;
547
+ if (flowRepo === undefined)
548
+ return out;
549
+ for (const trigger of store.flowTriggers(flowId)) {
550
+ if (trigger.kind !== "schedule" || trigger.state !== "active")
551
+ continue;
552
+ const digest = triggerDigestOf(trigger);
553
+ let config;
554
+ try {
555
+ config = JSON.parse(trigger.configJson);
556
+ }
557
+ catch {
558
+ continue;
559
+ }
560
+ if (digest === null || config === null || typeof config !== "object" || typeof config["schedule"] !== "string" || config["order"] !== undefined)
561
+ continue;
562
+ const when = scheduleWords(config["schedule"]);
563
+ if (config["kind"] === "plane-review")
564
+ out.push({ id: trigger.id, kind: "plane-review", digest, words: `Plane review, ${when}`, optional: "Its cards come from what the plane saw." });
565
+ else if (config["kind"] !== "schedule")
566
+ continue;
567
+ else if (typeof config["script"] === "string") {
568
+ // Its code is signed with it; one whose code can't be read safely is never offered.
569
+ const script = lastScript(store, flowRepo, config["script"]);
570
+ if (script !== null)
571
+ out.push({ id: trigger.id, kind: "script", digest, words: `the ${config["script"]} script, ${when}`, script, optional: "Its cards come from what the script prints, which can be outside text, and wait for you unless it ran the code you signed. Files it loads by a computed name aren't checked." });
572
+ }
573
+ else if (typeof config["title"] === "string")
574
+ out.push({ id: trigger.id, kind: "schedule", digest, words: `“${config["title"]}”, ${when}`, optional: null });
575
+ }
576
+ return out;
577
+ }
578
+ /** The flow's active script schedules not offered because their code can't be read safely. */
579
+ function unreadableScriptSchedules(store, flowId) {
580
+ const offered = new Set(unattendedCandidates(store, flowId).map(one => one.id));
581
+ return store.flowTriggers(flowId).filter(one => {
582
+ if (one.kind !== "schedule" || one.state !== "active" || offered.has(one.id))
583
+ return false;
584
+ try {
585
+ return typeof JSON.parse(one.configJson)["script"] === "string";
586
+ }
587
+ catch {
588
+ return false;
589
+ }
590
+ }).map(one => one.id);
591
+ }
592
+ const UNREADABLE_SCRIPT = "can't be read safely (it's missing, waiting for approval, or a project file it runs or imports can't be read)";
593
+ /** A digest of the schedules the page offered, so a signature never covers one the person didn't read. */
594
+ export const candidatesDigestOf = (candidates) => createHash("sha256").update(`standing-orders:flow-unattended-candidates:${canonicalJson(candidates)}`, "utf8").digest("hex").slice(0, 32);
595
+ /** Why `signer` can't let this flow run on its own, or null: what the flow page shows instead of the form. */
596
+ export function unattendedSignProblem(store, flow, signer) {
597
+ if (unattendedCandidates(store, flow.id).length === 0)
598
+ return unreadableScriptSchedules(store, flow.id).length > 0
599
+ ? `This flow's script ${UNREADABLE_SCRIPT}, so nothing would run on its own.` : "This flow has no schedule, so nothing would run on its own.";
600
+ return coverageProblem(store, flow, signer, workReposOf(flow));
601
+ }
602
+ /** Read what a person typed: whole days 1–MODE_MAX_DAYS, an optional dollar budget, an optional runs-a-day cap. */
603
+ export function parseUnattendedAsk(input) {
604
+ const text = (v) => typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "";
605
+ const days = Number(text(input.days));
606
+ if (!Number.isInteger(days) || days < 1 || days > MODE_MAX_DAYS)
607
+ return { ok: false, message: `Choose 1 to ${MODE_MAX_DAYS} days: it always ends.` };
608
+ const budgetText = text(input.budget).replace(/^\$/, "");
609
+ let budgetMicrousd = null;
610
+ if (budgetText !== "") {
611
+ const value = Number(budgetText);
612
+ if (!/^\d+(\.\d{1,2})?$/.test(budgetText) || value <= 0 || Math.round(value * 1_000_000) > UNATTENDED_MAX_BUDGET_MICROUSD)
613
+ return { ok: false, message: `Give the budget in dollars, up to ${dollars(UNATTENDED_MAX_BUDGET_MICROUSD)}, or leave it empty.` };
614
+ budgetMicrousd = Math.round(value * 1_000_000);
615
+ }
616
+ const runsText = text(input.runs);
617
+ let dailyRunCap = null;
618
+ if (runsText !== "") {
619
+ const value = Number(runsText);
620
+ if (!Number.isInteger(value) || value < 1 || value > UNATTENDED_MAX_RUNS)
621
+ return { ok: false, message: `Give runs a day as 1 to ${UNATTENDED_MAX_RUNS}, or leave it empty.` };
622
+ dailyRunCap = value;
623
+ }
624
+ const includeText = Array.isArray(input.include) ? input.include.map(text).join(",") : text(input.include);
625
+ const include = includeText === "" ? [] : includeText.split(/[\s,]+/).filter(one => one !== "").map(Number);
626
+ if (include.some(id => !Number.isSafeInteger(id) || id < 1))
627
+ return { ok: false, message: "Name the schedules to include by their trigger ids, like 12,14." };
628
+ return { ok: true, ask: { days, budgetMicrousd, dailyRunCap, ...(include.length === 0 ? {} : { include: [...new Set(include)] }) } };
629
+ }
630
+ /** The exact terms a signature would cover now, for these schedules. */
631
+ export function unattendedTermsFor(flow, owner, ask, triggers, now) {
632
+ return { flow: flow.id, flowRevision: flow.revision, repo: flow.repo, owner, triggers: triggers.map(({ id, kind, digest, words, script }) => ({ id, kind, digest, words, ...(kind === "script" && script !== undefined ? { script: structuredClone(script) } : {}) })), permissions: "safe",
633
+ acceptance: structuredClone(FLOW_ACCEPTANCE), perAttemptBudgetMicrousd: ask.budgetMicrousd, dailyRunCap: ask.dailyRunCap,
634
+ absoluteExpiry: new Date(now.getTime() + ask.days * 24 * 60 * 60_000).toISOString() };
635
+ }
636
+ /** Sign (or renew) the grant. The caller has already checked the signer's password; every predicate is proved again
637
+ * here against the flow as it now stands: files asynchronously first, then database facts inside the transaction. `sawRevision` and `sawSchedules` (candidatesDigestOf),
638
+ * when given, are the flow and the schedules the person read. Renewing ends the old grant and withdraws what it sealed. */
639
+ export async function signUnattended(store, input, now) {
640
+ const revision = store.getFlow(input.flow)?.revision;
641
+ const schedules = canonicalJson(store.flowTriggers(input.flow));
642
+ await prepareUnattendedCandidates(store, input.flow);
643
+ const prepared = candidatesDigestOf(unattendedCandidates(store, input.flow));
644
+ return store.transact(() => {
645
+ const flow = store.getFlow(input.flow);
646
+ if (flow === null)
647
+ return { ok: false, message: "No such flow." };
648
+ if (revision !== flow.revision || schedules !== canonicalJson(store.flowTriggers(input.flow)) || prepared !== candidatesDigestOf(unattendedCandidates(store, input.flow)))
649
+ return { ok: false, message: "This flow changed while preparing its scripts. Read the terms again." };
650
+ if (input.sawRevision !== undefined && input.sawRevision !== flow.revision)
651
+ return { ok: false, message: "This flow changed while you were reading. Read the terms again." };
652
+ const problem = unattendedSignProblem(store, flow, input.signer);
653
+ if (problem !== null)
654
+ return { ok: false, message: problem };
655
+ const candidates = unattendedCandidates(store, flow.id);
656
+ if (input.sawSchedules !== undefined && input.sawSchedules !== candidatesDigestOf(candidates))
657
+ return { ok: false, message: "This flow's schedules changed while you were reading. Read the terms again." };
658
+ const include = input.ask.include ?? [];
659
+ const unknown = include.find(id => !candidates.some(one => one.id === id));
660
+ if (unknown !== undefined)
661
+ return { ok: false, message: unreadableScriptSchedules(store, flow.id).includes(unknown)
662
+ ? `Trigger ${unknown} runs a script that ${UNREADABLE_SCRIPT}, so it can't be covered.` : `Trigger ${unknown} isn't one of this flow's active schedules.` };
663
+ const covered = candidates.filter(one => one.optional === null || include.includes(one.id));
664
+ if (covered.length === 0)
665
+ return { ok: false, message: "Choose a schedule to include: this flow's schedules bring in outside content, so none is covered unless you pick it." };
666
+ const terms = unattendedTermsFor(flow, input.signer, input.ask, covered, now);
667
+ const digest = unattendedDigestOf(terms);
668
+ const previous = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").get(flow.id);
669
+ if (previous !== undefined)
670
+ closeGrant(store, previous, input.signer, "renewed", now);
671
+ const id = Number(store.handle.prepare(`INSERT INTO flow_unattended_grant (flow, repo, flow_revision, terms_json, digest, signed_by, signed_at, absolute_expiry)
672
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(flow.id, flow.repo, flow.revision, unattendedTermsJson(terms), digest, input.signer, now.toISOString(), terms.absoluteExpiry).lastInsertRowid);
673
+ store.recordAction({ at: now.toISOString(), actor: input.signer, repo: flow.repo, taskId: null, runId: null, source: "policy",
674
+ action: `flow runs on its own: ${previous === undefined ? "signed" : "renewed"}`, outcome: previous === undefined ? "signed" : "renewed",
675
+ detail: `${flow.name} (flow #${flow.id}) · until ${terms.absoluteExpiry.slice(0, 16).replace("T", " ")} UTC${terms.perAttemptBudgetMicrousd === null ? "" : ` · ${dollars(terms.perAttemptBudgetMicrousd)} per attempt`}${terms.dailyRunCap === null ? "" : ` · ${terms.dailyRunCap} runs a day`}` });
676
+ return { ok: true, grant: { id, flow: flow.id, repo: flow.repo, terms, digest, signedBy: input.signer, signedAt: now.toISOString(), absoluteExpiry: terms.absoluteExpiry } };
677
+ });
678
+ }
679
+ /** One click for any approver on the project: lowering authority needs no password. False when there was none. */
680
+ export function revokeUnattended(store, flowId, by, now) {
681
+ return store.transact(() => {
682
+ const row = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").get(flowId);
683
+ if (row === undefined)
684
+ return false;
685
+ const expired = Date.parse(String(row["absolute_expiry"])) <= now.getTime();
686
+ closeGrant(store, row, expired ? "the-clock" : by, expired ? "expired" : "revoked", now);
687
+ return !expired;
688
+ });
689
+ }
690
+ /**
691
+ * Whether a card came from one of the schedules this grant signed for, fired on time after it was signed: the firing
692
+ * names the card and was the clock's (never Run now), its trigger is still active with the very settings signed, and no
693
+ * person has moved the card or sent it back since. Anything else is a person's card, or one the grant never read.
694
+ * A script schedule's firing must have run the very code signed (scriptRanDigestOf), and it must still be that code;
695
+ * when it isn't, the grant ends here (unattendedCoverageFor).
696
+ */
697
+ export function cardFromSignedSchedule(store, grant, cardId, requireScript = true) {
698
+ const fired = store.handle.prepare(`SELECT t.id, t.flow, t.kind, t.state, t.config_json, e.at, e.ran_script FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger
699
+ WHERE e.card = ? AND t.flow = ? AND e.on_time = 1`).all(cardId, grant.flow);
700
+ const signed = fired.some(row => String(row["at"]) >= grant.signedAt && String(row["state"]) === "active" && grant.terms.triggers.some(one => one.id === Number(row["id"]) &&
701
+ one.digest === triggerDigestOf({ id: Number(row["id"]), flow: Number(row["flow"]), kind: String(row["kind"]), configJson: String(row["config_json"]) }) &&
702
+ (!requireScript || one.kind !== "script" || (one.script !== undefined && row["ran_script"] === ranDigestOf(one.script)))));
703
+ if (!signed)
704
+ return false;
705
+ return store.handle.prepare(`SELECT 1 AS hit FROM flow_event WHERE card = ? AND ((outcome = 'moved' AND actor <> 'flow') OR outcome = 'sent-back') LIMIT 1`).get(cardId) === undefined;
706
+ }
707
+ const utcDay = (now) => now.toISOString().slice(0, 10);
708
+ /** How many runs a grant has filed on `now`'s UTC day. */
709
+ export function unattendedRunsToday(store, grantId, now) {
710
+ return Number(store.handle.prepare("SELECT COUNT(*) AS n FROM flow_unattended_run WHERE grant_id = ? AND day = ?").get(grantId, utcDay(now))?.["n"] ?? 0);
711
+ }
712
+ /** Whether the grant covers this card's next filing (in `repo`) now; call inside the filing transaction. */
713
+ export function unattendedCoverageFor(store, flow, card, repo, now) {
714
+ const grant = activeUnattendedGrant(store, flow.id, now);
715
+ if (grant === null || grant.terms.flow !== flow.id || grant.terms.flowRevision !== flow.revision || grant.terms.repo !== flow.repo || grant.terms.owner !== grant.signedBy)
716
+ return null;
717
+ if (coverageProblem(store, flow, grant.signedBy, [repo]) !== null)
718
+ return null;
719
+ // A script that drifted ends the grant whichever card is filing; one restored since a firing leaves just that card out.
720
+ if (!cardFromSignedSchedule(store, grant, card.id, false))
721
+ return null;
722
+ if (endIfScriptsChanged(store, grant, now))
723
+ return null;
724
+ if (!cardFromSignedSchedule(store, grant, card.id))
725
+ return null;
726
+ if (grant.terms.dailyRunCap !== null && unattendedRunsToday(store, grant.id, now) >= grant.terms.dailyRunCap)
727
+ return null;
728
+ return grant;
729
+ }
730
+ /** Count one run against the grant's day, in one statement that admits it only under the cap (so two filings racing
731
+ * on two connections can't both take the last run). False when the day is full; then nothing is sealed. */
732
+ export function reserveUnattendedRun(store, grant, taskId, now) {
733
+ const { changes } = store.handle.prepare(`INSERT OR IGNORE INTO flow_unattended_run (grant_id, day, task_id, at)
734
+ SELECT ?, ?, ?, ? WHERE ? IS NULL OR (SELECT COUNT(*) FROM flow_unattended_run WHERE grant_id = ? AND day = ?) < ?`)
735
+ .run(grant.id, utcDay(now), taskId, now.toISOString(), grant.terms.dailyRunCap, grant.id, utcDay(now), grant.terms.dailyRunCap);
736
+ return Number(changes) === 1;
737
+ }
738
+ /** Give back a run reserved for a filing that then couldn't be sealed. */
739
+ export function releaseUnattendedRun(store, grant, taskId) {
740
+ store.handle.prepare("DELETE FROM flow_unattended_run WHERE grant_id = ? AND task_id = ?").run(grant.id, taskId);
741
+ }
742
+ /** The grant that sealed (or pre-authorized) a task still stands for it: the same live digest, the flow unchanged and
743
+ * still owned by the signer, the signer able to approve in the task's project, its scripts' code unchanged (else it
744
+ * ends here), and the work not protected. */
745
+ export function unattendedGrantStands(store, digest, signer, taskRepo, touches, now) {
746
+ const row = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE digest = ? AND revoked_at IS NULL").get(digest);
747
+ if (row === undefined || taskRepo === null)
748
+ return false;
749
+ const grant = activeUnattendedGrant(store, Number(row["flow"]), now);
750
+ const flow = grant === null ? null : store.getFlow(grant.flow);
751
+ if (grant === null || flow === null || grant.digest !== digest || grant.signedBy !== signer || grant.terms.flowRevision !== flow.revision)
752
+ return false;
753
+ if (coverageProblem(store, flow, signer, [taskRepo]) !== null)
754
+ return false;
755
+ if (endIfScriptsChanged(store, grant, now))
756
+ return false;
757
+ return !isProtectedWork(store.approvalRules(taskRepo), [...touches]);
758
+ }
759
+ export function unattendedViewOf(store, flow, viewer, now) {
760
+ const grant = activeUnattendedGrant(store, flow.id, now);
761
+ // A grant whose flow moved on covers nothing: say so by not showing it as running.
762
+ const live = grant !== null && grant.terms.flowRevision === flow.revision && grant.signedBy === flow.owner ? grant : null;
763
+ const problem = viewer.approver ? unattendedSignProblem(store, flow, viewer.name) : "Only an approver can change this.";
764
+ const candidates = unattendedCandidates(store, flow.id);
765
+ return {
766
+ active: live === null ? null : { until: live.absoluteExpiry, untilWords: untilWords(live.absoluteExpiry), signedBy: live.signedBy, budgetMicrousd: live.terms.perAttemptBudgetMicrousd,
767
+ dailyRunCap: live.terms.dailyRunCap, runsToday: unattendedRunsToday(store, live.id, now), words: unattendedWords(live.terms), covers: live.terms.triggers.map(one => one.words) },
768
+ canSign: problem === null,
769
+ problem,
770
+ maxDays: MODE_MAX_DAYS,
771
+ scheduled: candidates.length > 0,
772
+ terms: { head: unattendedHeadWords(viewer.name, FLOW_ACCEPTANCE), rules: [...UNATTENDED_RULE_WORDS] },
773
+ schedules: candidates.map(one => ({ id: one.id, words: one.words, optional: one.optional })),
774
+ schedulesDigest: candidatesDigestOf(candidates),
775
+ };
776
+ }