toolroll 0.9.61 → 0.9.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -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).
|
|
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
|
|
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. */
|
package/dist/flow-triggers.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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(
|
|
343
|
-
return typeof parsed
|
|
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) {
|
|
350
|
-
const
|
|
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
|
|
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 =
|
|
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
|
-
|
|
372
|
-
|
|
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) {
|
|
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
|
-
|
|
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 &&
|
|
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;
|