toolroll 0.9.33 → 0.9.36
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 +57 -0
- package/README.md +7 -0
- package/dist/assignment.d.ts +1 -0
- package/dist/assignment.js +2 -2
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +17 -17
- package/dist/browser-crew.js +1 -0
- package/dist/browser-workspace.d.ts +5 -3
- package/dist/builder.js +4 -7
- package/dist/chat-acceptance.d.ts +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +124 -63
- package/dist/contracts/contract-test.d.ts +45 -0
- package/dist/contracts/contract-test.js +69 -0
- package/dist/contracts/contract.d.ts +87 -0
- package/dist/contracts/contract.js +308 -0
- package/dist/contracts/flow-propose.d.ts +325 -0
- package/dist/contracts/flow-propose.js +41 -0
- package/dist/contracts/flow.d.ts +19400 -0
- package/dist/contracts/flow.js +359 -0
- package/dist/contracts/handoff.d.ts +91 -0
- package/dist/contracts/handoff.js +82 -0
- package/dist/contracts/plan.d.ts +58 -0
- package/dist/contracts/plan.js +67 -0
- package/dist/contracts/proof.d.ts +136 -0
- package/dist/contracts/proof.js +110 -0
- package/dist/contracts/verification-receipt.d.ts +148 -0
- package/dist/contracts/verification-receipt.js +87 -0
- package/dist/demo.js +28 -11
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence.d.ts +3 -40
- package/dist/evidence.js +7 -3
- package/dist/flow-gallery.d.ts +3 -2
- package/dist/flow-share.d.ts +10 -32
- package/dist/flow-share.js +65 -83
- package/dist/flow-starters.d.ts +2 -1
- package/dist/flow-triggers.d.ts +15 -72
- package/dist/flow-triggers.js +94 -83
- package/dist/flows.d.ts +37 -218
- package/dist/flows.js +350 -385
- package/dist/mate-tools.js +14 -44
- package/dist/mcp-connect.d.ts +3 -0
- package/dist/mcp-connect.js +16 -4
- package/dist/operate.js +83 -12
- package/dist/plan.d.ts +12 -12
- package/dist/plan.js +109 -72
- package/dist/planner-source.js +2 -1
- package/dist/planner.js +55 -25
- package/dist/process-recovery-eligibility.d.ts +2 -2
- package/dist/process-recovery-eligibility.js +13 -8
- package/dist/project-admission.d.ts +93 -0
- package/dist/project-admission.js +134 -0
- package/dist/project-delete-ui.js +5 -2
- package/dist/proof.d.ts +15 -91
- package/dist/proof.js +90 -249
- package/dist/result-evidence-readers.d.ts +42 -0
- package/dist/result-evidence-readers.js +91 -0
- package/dist/runner.d.ts +10 -0
- package/dist/runner.js +16 -0
- package/dist/scope.js +4 -3
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +112 -160
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +2 -0
- package/dist/store.js +4 -0
- package/dist/surface.js +16 -3
- package/dist/task-review-brief.d.ts +75 -0
- package/dist/task-review-brief.js +179 -0
- package/dist/text-limits.d.ts +87 -0
- package/dist/text-limits.js +86 -0
- package/dist/verification-evidence.js +24 -12
- package/dist/when-html.d.ts +2 -0
- package/dist/when-html.js +13 -0
- package/package.json +3 -2
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
+
version?: never;
|
|
128
129
|
active: boolean;
|
|
129
130
|
phase: string;
|
|
130
131
|
detail: string;
|
|
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
138
139
|
wasRunning?: never;
|
|
139
140
|
backupPath?: never;
|
|
140
141
|
updateId?: never;
|
|
141
|
-
version?: never;
|
|
142
142
|
buildId?: never;
|
|
143
143
|
workDir?: never;
|
|
144
144
|
} | {
|
package/dist/evidence.d.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { type ParsedReport, type ReportImage } from "./scout-report.js";
|
|
17
17
|
import { type ParsedProof } from "./proof.js";
|
|
18
|
+
import { type HandoffArtifact } from "./contracts/handoff.js";
|
|
19
|
+
export type { HandoffArtifact } from "./contracts/handoff.js";
|
|
18
20
|
import type { Artifact, Store } from "./store.js";
|
|
19
21
|
import type { ExecResult } from "./exec.js";
|
|
20
22
|
/** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
|
|
@@ -254,45 +256,6 @@ export type DiffStat = {
|
|
|
254
256
|
* recorded as null — never coerced to zero.
|
|
255
257
|
*/
|
|
256
258
|
export declare function parseNumstat(raw: string, base: string, head: string): DiffStat;
|
|
257
|
-
/** The freshness-stamped handoff artifact's shape (M6.10). */
|
|
258
|
-
export type HandoffArtifact = {
|
|
259
|
-
schema: 1;
|
|
260
|
-
taskId: string;
|
|
261
|
-
runId: number;
|
|
262
|
-
provider: string;
|
|
263
|
-
/** v47: the exact model that ran, and the sealed route leg it ran as —
|
|
264
|
-
* provenance a later reader can hold against the approval. Absent on
|
|
265
|
-
* artifacts written before routes existed. */
|
|
266
|
-
model?: string;
|
|
267
|
-
route?: {
|
|
268
|
-
digest: string;
|
|
269
|
-
phase: "plan" | "build" | "repair" | "review";
|
|
270
|
-
provider: string;
|
|
271
|
-
model: string | null;
|
|
272
|
-
chosen: "recommended" | "override" | "pinned" | "legacy" | "fallback";
|
|
273
|
-
};
|
|
274
|
-
sessionId: string | null;
|
|
275
|
-
branch: string;
|
|
276
|
-
worktree: string;
|
|
277
|
-
base: string;
|
|
278
|
-
head: string;
|
|
279
|
-
outcome: "built" | "no-change";
|
|
280
|
-
committed: boolean;
|
|
281
|
-
/** Decision ids whose answers were in this run's brief — causality, not time. */
|
|
282
|
-
decisionsIncorporated: number[];
|
|
283
|
-
/** The agent's conclusion — agent-reported, and labeled so by its position here. */
|
|
284
|
-
conclusion: string;
|
|
285
|
-
/** Structured operator-facing output. Optional for artifacts written by
|
|
286
|
-
* older versions; readers treat absence as an empty list. */
|
|
287
|
-
changes?: string[];
|
|
288
|
-
verification?: string[];
|
|
289
|
-
followUps?: string[];
|
|
290
|
-
freshness: {
|
|
291
|
-
stampedAt: string;
|
|
292
|
-
/** A successor proves this against the branch before trusting anything above. */
|
|
293
|
-
currentAsOf: string;
|
|
294
|
-
};
|
|
295
|
-
};
|
|
296
259
|
/**
|
|
297
260
|
* Write the handoff artifact (M6.10): the machine's own statement of where
|
|
298
261
|
* a finished run left the world — workspace identity, exact base and head,
|
|
@@ -301,7 +264,7 @@ export type HandoffArtifact = {
|
|
|
301
264
|
* context. This is what makes a cold takeover context-bearing rather than
|
|
302
265
|
* repo-only; a provider session is memory, not a freshness proof.
|
|
303
266
|
*/
|
|
304
|
-
export declare function storeHandoffArtifact(store: Store, root: string, payload: HandoffArtifact, now: Date): number;
|
|
267
|
+
export declare function storeHandoffArtifact(store: Store, root: string, payload: Omit<HandoffArtifact, "version">, now: Date): number;
|
|
305
268
|
/**
|
|
306
269
|
* High-confidence secret shapes ONLY (audit IV-7): a detector that cries
|
|
307
270
|
* wolf trains people to approve wolves. Each pattern is a format no
|
package/dist/evidence.js
CHANGED
|
@@ -18,9 +18,10 @@ import { closeSync, existsSync, constants, fstatSync, mkdirSync, openSync, readd
|
|
|
18
18
|
import { join, sep } from "node:path";
|
|
19
19
|
import { namedPath } from "./names.js";
|
|
20
20
|
import { LIMITS } from "./decision.js";
|
|
21
|
-
import {
|
|
21
|
+
import { TEXT_LIMITS } from "./text-limits.js";
|
|
22
22
|
import { parseReport, REPORT_LIMITS } from "./scout-report.js";
|
|
23
23
|
import { parseProof, PROOF_LIMITS } from "./proof.js";
|
|
24
|
+
import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
|
|
24
25
|
import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
|
|
25
26
|
/** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
|
|
26
27
|
export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
|
|
@@ -84,7 +85,7 @@ export const EVIDENCE_CAPS = {
|
|
|
84
85
|
diff: 256 * 1024,
|
|
85
86
|
status: 64 * 1024,
|
|
86
87
|
"park-payload": LIMITS.payload,
|
|
87
|
-
plan:
|
|
88
|
+
plan: TEXT_LIMITS.planDocumentBytes,
|
|
88
89
|
"terminal-diff": 256 * 1024,
|
|
89
90
|
"diff-stat": 32 * 1024,
|
|
90
91
|
// The enveloped base snapshot (live peek): 20k entries of path+sha+size.
|
|
@@ -618,7 +619,10 @@ export function parseNumstat(raw, base, head) {
|
|
|
618
619
|
* repo-only; a provider session is memory, not a freshness proof.
|
|
619
620
|
*/
|
|
620
621
|
export function storeHandoffArtifact(store, root, payload, now) {
|
|
621
|
-
|
|
622
|
+
const read = readHandoffArtifact({ version: HANDOFF_VERSION, ...payload });
|
|
623
|
+
if (!read.ok)
|
|
624
|
+
throw new Error(`The handoff does not match its contract: ${read.issues.map(issue => issue.line).join("; ")}`);
|
|
625
|
+
return storeEvidence(store, root, payload.runId, "handoff", "handoff.json", Buffer.from(JSON.stringify(read.value, null, 2), "utf8"), "machine-authored handoff (exit 0)", now);
|
|
622
626
|
}
|
|
623
627
|
/**
|
|
624
628
|
* High-confidence secret shapes ONLY (audit IV-7): a detector that cries
|
package/dist/flow-gallery.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FlowDefinition, type FlowStepInput } from "./flows.js";
|
|
2
|
+
import type { TriggerInput } from "./contracts/flow.js";
|
|
2
3
|
import type { Store } from "./store.js";
|
|
3
4
|
export declare const GALLERY_GROUPS: readonly [{
|
|
4
5
|
readonly id: "ship";
|
|
@@ -46,7 +47,7 @@ export type GalleryTemplate = {
|
|
|
46
47
|
/** The steps in Toolroll's own repository, when they differ. */
|
|
47
48
|
ownSteps?: FlowStepInput[];
|
|
48
49
|
definition?: FlowDefinition;
|
|
49
|
-
triggers:
|
|
50
|
+
triggers: TriggerInput[];
|
|
50
51
|
scripts?: {
|
|
51
52
|
name: string;
|
|
52
53
|
about: string;
|
|
@@ -100,7 +101,7 @@ export type GalleryBuilt = {
|
|
|
100
101
|
repo: string;
|
|
101
102
|
answers: GalleryAnswers;
|
|
102
103
|
definition: FlowDefinition;
|
|
103
|
-
triggers:
|
|
104
|
+
triggers: TriggerInput[];
|
|
104
105
|
scripts: {
|
|
105
106
|
name: string;
|
|
106
107
|
about: string;
|
package/dist/flow-share.d.ts
CHANGED
|
@@ -1,39 +1,11 @@
|
|
|
1
|
-
import { type ScriptDraft } from "./flow-scripts.js";
|
|
2
1
|
import { type FlowDefinition } from "./flows.js";
|
|
2
|
+
import { FLOW_FILE_FORMAT, FLOW_FILE_VERSION, type FlowFile, type FlowFileParameter, type FlowFileScript } from "./contracts/flow.js";
|
|
3
3
|
import type { FlowRow, Store } from "./store.js";
|
|
4
|
-
export
|
|
5
|
-
export
|
|
4
|
+
export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
|
|
5
|
+
export type { FlowFile, FlowFileParameter, FlowFileScript };
|
|
6
6
|
/** The largest flow file Toolroll reads or fetches. */
|
|
7
7
|
export declare const FLOW_FILE_MAX_BYTES: number;
|
|
8
8
|
export declare const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
|
|
9
|
-
/** Something the import asks for: `{{param.<id>}}` in a zone or trigger is replaced by its value. */
|
|
10
|
-
export type FlowFileParameter = {
|
|
11
|
-
id: string;
|
|
12
|
-
about: string;
|
|
13
|
-
default?: string;
|
|
14
|
-
optional?: boolean;
|
|
15
|
-
};
|
|
16
|
-
export type FlowFileScript = {
|
|
17
|
-
name: string;
|
|
18
|
-
about: string;
|
|
19
|
-
language: ScriptDraft["language"];
|
|
20
|
-
timeoutMinutes: number;
|
|
21
|
-
body?: string;
|
|
22
|
-
file?: string;
|
|
23
|
-
};
|
|
24
|
-
export type FlowFile = {
|
|
25
|
-
format: typeof FLOW_FILE_FORMAT;
|
|
26
|
-
version: typeof FLOW_FILE_VERSION;
|
|
27
|
-
name: string;
|
|
28
|
-
about: string;
|
|
29
|
-
needs: string[];
|
|
30
|
-
parameters: FlowFileParameter[];
|
|
31
|
-
/** The zones in the lead's step vocabulary, the start first. */
|
|
32
|
-
zones: Record<string, unknown>[];
|
|
33
|
-
/** Each trigger's settings, as `toolroll flows trigger add` takes them. */
|
|
34
|
-
triggers: Record<string, unknown>[];
|
|
35
|
-
scripts: FlowFileScript[];
|
|
36
|
-
};
|
|
37
9
|
export type FlowExported = {
|
|
38
10
|
file: FlowFile;
|
|
39
11
|
json: string;
|
|
@@ -42,9 +14,15 @@ export type FlowExported = {
|
|
|
42
14
|
};
|
|
43
15
|
/** A flow as a file: its zones and paths, its triggers' settings and the scripts it runs, nothing of this installation's. */
|
|
44
16
|
export declare function exportFlow(store: Store, flow: FlowRow, dir: string | null): FlowExported;
|
|
17
|
+
/** A flow file refused: why, in plain words, each problem naming its path in the file (`zones[0].routes[0]: ...`). */
|
|
45
18
|
export declare class FlowFileError extends Error {
|
|
46
19
|
}
|
|
47
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Read a flow file: its size, its JSON and format, then its one schema (flowFileSchema: every zone, trigger, script
|
|
22
|
+
* and parameter, strict about unknown keys — `zones[0].routes[0]: unknown key 'to' (did you mean goesTo?)`), and a
|
|
23
|
+
* newer version refused plainly. Then what the schema can't say: no keys in a trigger, ids and names used once, each
|
|
24
|
+
* {{param.x}} declared, every script valid and the zones a flow. Throws FlowFileError in plain words.
|
|
25
|
+
*/
|
|
48
26
|
export declare function parseFlowFile(text: string): FlowFile;
|
|
49
27
|
/** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
|
|
50
28
|
export declare function parameterValues(file: FlowFile, given: Record<string, string>): Record<string, string>;
|
package/dist/flow-share.js
CHANGED
|
@@ -21,29 +21,30 @@ import { flowDefinitionOf } from "./flow-engine.js";
|
|
|
21
21
|
import { readFlowSecrets } from "./flow-secrets.js";
|
|
22
22
|
import { scriptDigest, validateScript } from "./flow-scripts.js";
|
|
23
23
|
import { addFlowTriggerTo, describeTrigger, HOOK_PATH, readHooksBase, triggerConfigOf, validateTriggerConfig } from "./flow-triggers.js";
|
|
24
|
-
import { durationMinutes, durationWords, flowTerms, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
|
|
24
|
+
import { durationMinutes, durationWords, FlowContractError, flowTerms, inStepWords, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
|
|
25
|
+
import { readVersioned } from "./contracts/contract.js";
|
|
26
|
+
import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
|
|
25
27
|
import { parseSchedule } from "./routine.js";
|
|
26
|
-
export
|
|
27
|
-
export const FLOW_FILE_VERSION = 1;
|
|
28
|
+
export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
|
|
28
29
|
/** The largest flow file Toolroll reads or fetches. */
|
|
29
30
|
export const FLOW_FILE_MAX_BYTES = 256 * 1024;
|
|
30
31
|
export const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
|
|
31
32
|
const PARAM = /\{\{\s*param\.([a-z0-9][a-z0-9-]{0,39})\s*\}\}/g;
|
|
32
|
-
const PARAM_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
33
33
|
const REMOVED = "[removed]";
|
|
34
34
|
// ------------------------------------------------------------------ export
|
|
35
35
|
/** A zone in the lead's words: only what it says, in the order a person reads it. */
|
|
36
36
|
function stepOf(stage, ask) {
|
|
37
37
|
const step = { id: stage.id, title: stage.title, kind: stage.kind };
|
|
38
38
|
const words = (minutes) => durationMinutes(durationWords(minutes)) === minutes ? durationWords(minutes) : minutes;
|
|
39
|
-
|
|
39
|
+
// Only what the step's kind says: words a zone kept from a kind it was before stay behind.
|
|
40
|
+
if (stage.instructions !== null && (stage.kind === "task" || stage.kind === "report" || stage.kind === "draft" || stage.kind === "teammate"))
|
|
40
41
|
step["instructions"] = stage.instructions;
|
|
41
42
|
if (stage.kind === "task")
|
|
42
43
|
step["planning"] = stage.planning;
|
|
43
44
|
if (stage.kind === "approval")
|
|
44
45
|
step["decider"] = stage.toOwner === true ? "owner" : stage.approver === null ? "anyone"
|
|
45
46
|
: ask({ id: `decider-${stage.id}`.slice(0, 40), about: `Who decides at ${stage.title}: a person's sign-in name, owner (the flow's owner) or anyone`, default: "owner" });
|
|
46
|
-
if (stage.message !== null)
|
|
47
|
+
if (stage.message !== null && (stage.kind === "notify" || stage.kind === "update"))
|
|
47
48
|
step["message"] = stage.message;
|
|
48
49
|
if (stage.kind === "update")
|
|
49
50
|
step["close"] = stage.close;
|
|
@@ -239,26 +240,28 @@ export function exportFlow(store, flow, dir) {
|
|
|
239
240
|
format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name: flow.name,
|
|
240
241
|
about: ordered.map(one => one.title).join(" → "),
|
|
241
242
|
needs: needsOf(definition, triggers, scripts.map(one => one.name)),
|
|
242
|
-
|
|
243
|
+
// Zones and triggers in the file's words (stepOf, triggerOf): what flowFileSchema reads back (flow-share.test.ts).
|
|
244
|
+
parameters, zones: zones, triggers: triggers, scripts,
|
|
243
245
|
};
|
|
244
246
|
const file = scrubDeep(raw, scrubber(values, names), null, false);
|
|
245
247
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "flow";
|
|
246
248
|
return { file, json: `${JSON.stringify(file, null, 2)}\n`, fileName: `${slug}${FLOW_FILE_SUFFIX}`, left };
|
|
247
249
|
}
|
|
248
250
|
// ------------------------------------------------------------------ reading
|
|
251
|
+
/** A flow file refused: why, in plain words, each problem naming its path in the file (`zones[0].routes[0]: ...`). */
|
|
249
252
|
export class FlowFileError extends Error {
|
|
250
253
|
}
|
|
251
254
|
const refuse = (message) => { throw new FlowFileError(message); };
|
|
252
255
|
/** A zone from the file as the canvas stores it: references by id (or by title), the rest checked by validateFlowDefinition. */
|
|
253
256
|
function stageInputOf(step, index, find) {
|
|
254
|
-
const
|
|
257
|
+
const at = `zones[${index}]`;
|
|
255
258
|
const kind = step["kind"];
|
|
256
259
|
const object = (key) => {
|
|
257
260
|
const value = step[key];
|
|
258
261
|
if (value === undefined || value === null)
|
|
259
262
|
return undefined;
|
|
260
263
|
if (typeof value !== "object" || Array.isArray(value))
|
|
261
|
-
refuse(
|
|
264
|
+
refuse(`${at}.${key}: must be an object`);
|
|
262
265
|
return value;
|
|
263
266
|
};
|
|
264
267
|
const list = (key) => {
|
|
@@ -266,59 +269,60 @@ function stageInputOf(step, index, find) {
|
|
|
266
269
|
if (value === undefined || value === null)
|
|
267
270
|
return undefined;
|
|
268
271
|
if (!Array.isArray(value) || value.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
|
|
269
|
-
refuse(
|
|
272
|
+
refuse(`${at}.${key}: must be a list of objects`);
|
|
270
273
|
return value;
|
|
271
274
|
};
|
|
272
275
|
const decider = step["decider"];
|
|
273
276
|
if (decider !== undefined && decider !== null && typeof decider !== "string")
|
|
274
|
-
refuse(
|
|
277
|
+
refuse(`${at}.decider: must be a string`);
|
|
275
278
|
const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
|
|
276
279
|
const anyone = decider === undefined || decider === null || (typeof decider === "string" && /^(anyone|any approver|)$/i.test(decider.trim()));
|
|
277
|
-
const
|
|
278
|
-
const failKey =
|
|
279
|
-
const minutes = (value) => value === undefined ? undefined : durationMinutes(value) ?? refuse(
|
|
280
|
+
const place = object("at") ?? {};
|
|
281
|
+
const [failKey, fail] = ["ifNotSure", "ifNoReply", "ifReplied", "ifFails"].map(key => [key, step[key]]).find(([, value]) => value !== undefined && value !== null) ?? ["ifFails", undefined];
|
|
282
|
+
const minutes = (value, key) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`${at}.${key}: say a time like "3 days" (up to 30 days)`);
|
|
280
283
|
const sureAt = step["sureAt"];
|
|
281
284
|
return {
|
|
282
285
|
id: step["id"], title: step["title"], kind,
|
|
283
|
-
zone: { x:
|
|
286
|
+
zone: { x: place["x"], y: place["y"] ?? 0, w: place["w"], h: place["h"], color: place["color"] },
|
|
284
287
|
instructions: step["instructions"], planning: step["planning"],
|
|
285
288
|
...(toOwner ? { toOwner: true } : { approver: anyone ? null : decider }),
|
|
286
289
|
message: step["message"], close: step["close"], script: step["script"], runIn: step["runIn"],
|
|
287
|
-
routes: list("routes")?.map(one => ({ answer: one["answer"], to: find(one["goesTo"],
|
|
290
|
+
routes: list("routes")?.map((one, n) => ({ answer: one["answer"], to: find(one["goesTo"], `${at}.routes[${n}].goesTo`) })),
|
|
288
291
|
secrets: step["secrets"],
|
|
289
|
-
...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map(one => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"],
|
|
292
|
+
...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map((one, n) => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"], `${at}.answers[${n}].goesTo`) })),
|
|
290
293
|
sureAt: typeof sureAt === "number" && sureAt > 1 ? sureAt / 100 : sureAt, notes: list("alsoNote") ?? [] } } : {}),
|
|
291
294
|
...(kind === "request" ? { request: { method: step["method"], url: step["url"], headers: object("headers") ?? {}, body: step["body"] } } : {}),
|
|
292
295
|
...(kind === "email" ? { email: { to: step["to"], subject: step["subject"], body: step["body"] } } : {}),
|
|
293
296
|
...(kind === "tool" ? { tool: { server: step["server"], name: step["tool"], args: typeof step["args"] === "object" && step["args"] !== null ? JSON.stringify(step["args"]) : step["args"] } } : {}),
|
|
294
|
-
...(kind === "wait" ? { wait: step["waitFor"] === "hours" ? { for: "hours", from: step["from"], to: step["until"], timeZone: step["timeZone"] } : { for: step["waitFor"] ?? "reply", minutes: minutes(step["wait"] ?? "3 days") } } : {}),
|
|
297
|
+
...(kind === "wait" ? { wait: step["waitFor"] === "hours" ? { for: "hours", from: step["from"], to: step["until"], timeZone: step["timeZone"] } : { for: step["waitFor"] ?? "reply", minutes: minutes(step["wait"] ?? "3 days", "wait") } } : {}),
|
|
295
298
|
merge: step["merge"], teammate: step["teammate"], reply: step["reply"],
|
|
296
|
-
...(kind === "choose" ? { options: (list("options") ?? []).map(one => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"],
|
|
297
|
-
...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"]), to: find(step["thenMoveTo"],
|
|
298
|
-
next: find(step["next"],
|
|
299
|
+
...(kind === "choose" ? { options: (list("options") ?? []).map((one, n) => ({ label: one["label"], to: one["goesTo"] === "end" ? "end" : find(one["goesTo"], `${at}.options[${n}].goesTo`) })) } : {}),
|
|
300
|
+
...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"], "remindAfter"), to: find(step["thenMoveTo"], `${at}.thenMoveTo`) } }),
|
|
301
|
+
next: find(step["next"], `${at}.next`), onFail: find(fail, `${at}.${failKey}`),
|
|
299
302
|
};
|
|
300
303
|
}
|
|
301
304
|
/** The zones as a flow, checked whole. Throws in plain words. */
|
|
302
305
|
function definitionOf(zones) {
|
|
303
306
|
const ids = zones.map(one => one["id"]);
|
|
304
|
-
const find = (ref,
|
|
307
|
+
const find = (ref, path) => {
|
|
305
308
|
if (ref === undefined || ref === null || ref === "")
|
|
306
309
|
return null;
|
|
307
310
|
if (typeof ref !== "string")
|
|
308
|
-
return refuse(
|
|
311
|
+
return refuse(`${path}: names a zone by its id`);
|
|
309
312
|
const hit = zones.find(one => one["id"] === ref.trim()) ?? zones.find(one => typeof one["title"] === "string" && one["title"].trim().toLowerCase() === ref.trim().toLowerCase());
|
|
310
313
|
if (hit === undefined)
|
|
311
|
-
return refuse(
|
|
312
|
-
return typeof hit["id"] === "string" ? hit["id"] : refuse(`
|
|
314
|
+
return refuse(`${path}: there's no zone called ${ref}`);
|
|
315
|
+
return typeof hit["id"] === "string" ? hit["id"] : refuse(`zones[${zones.indexOf(hit)}].id: required`);
|
|
313
316
|
};
|
|
314
|
-
|
|
315
|
-
refuse(
|
|
317
|
+
ids.forEach((one, index) => { if (typeof one !== "string")
|
|
318
|
+
refuse(`zones[${index}].id: required`); });
|
|
316
319
|
const stages = zones.map((step, index) => stageInputOf(step, index, find));
|
|
320
|
+
// Its zones are the file's, in order: a zone's problem names the file's zone.
|
|
317
321
|
try {
|
|
318
322
|
return validateFlowDefinition({ version: 1, start: stages[0]?.["id"], stages });
|
|
319
323
|
}
|
|
320
324
|
catch (error) {
|
|
321
|
-
return refuse(error instanceof Error ? error.message : "Those zones aren't a flow.");
|
|
325
|
+
return refuse(error instanceof FlowContractError ? error.lines.map(line => inStepWords(line, "zones", zones.map(one => typeof one["kind"] === "string" ? one["kind"] : undefined))).join("\n") : error instanceof Error ? error.message : "Those zones aren't a flow.");
|
|
322
326
|
}
|
|
323
327
|
}
|
|
324
328
|
const plainText = (value, cap, what, required) => {
|
|
@@ -328,17 +332,22 @@ const plainText = (value, cap, what, required) => {
|
|
|
328
332
|
return refuse(`The file's ${what} must be plain text.`);
|
|
329
333
|
if (value.length > cap)
|
|
330
334
|
return refuse(`The file's ${what} is longer than ${cap} characters.`);
|
|
331
|
-
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f
|
|
335
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(value))
|
|
332
336
|
return refuse(`The file's ${what} has hidden characters in it.`);
|
|
333
337
|
return value.trim();
|
|
334
338
|
};
|
|
335
|
-
/**
|
|
339
|
+
/**
|
|
340
|
+
* Read a flow file: its size, its JSON and format, then its one schema (flowFileSchema: every zone, trigger, script
|
|
341
|
+
* and parameter, strict about unknown keys — `zones[0].routes[0]: unknown key 'to' (did you mean goesTo?)`), and a
|
|
342
|
+
* newer version refused plainly. Then what the schema can't say: no keys in a trigger, ids and names used once, each
|
|
343
|
+
* {{param.x}} declared, every script valid and the zones a flow. Throws FlowFileError in plain words.
|
|
344
|
+
*/
|
|
336
345
|
export function parseFlowFile(text) {
|
|
337
346
|
if (Buffer.byteLength(text, "utf8") > FLOW_FILE_MAX_BYTES)
|
|
338
347
|
refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
|
|
339
348
|
let raw;
|
|
340
349
|
try {
|
|
341
|
-
raw = JSON.parse(text.replace(
|
|
350
|
+
raw = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
342
351
|
}
|
|
343
352
|
catch {
|
|
344
353
|
return refuse("That isn't a flow file: it isn't valid JSON.");
|
|
@@ -348,75 +357,48 @@ export function parseFlowFile(text) {
|
|
|
348
357
|
const input = raw;
|
|
349
358
|
if (input["format"] !== FLOW_FILE_FORMAT)
|
|
350
359
|
refuse("That isn't a Toolroll flow file: its format isn't \"toolroll-flow\".");
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
: `This flow file has no version Toolroll reads; it should say "version": ${FLOW_FILE_VERSION}.`);
|
|
355
|
-
const name = plainText(input["name"], 80, "name", true);
|
|
356
|
-
const about = plainText(input["about"], 600, "about line", false);
|
|
357
|
-
const array = (key, cap) => {
|
|
358
|
-
const value = input[key];
|
|
359
|
-
if (value === undefined || value === null)
|
|
360
|
-
return [];
|
|
361
|
-
if (!Array.isArray(value))
|
|
362
|
-
return refuse(`The file's ${key} must be a list.`);
|
|
363
|
-
if (value.length > cap)
|
|
364
|
-
return refuse(`The file has more than ${cap} ${key}.`);
|
|
365
|
-
return value;
|
|
366
|
-
};
|
|
367
|
-
const needs = array("needs", 40).map(one => plainText(one, 80, "needs", true));
|
|
368
|
-
const parameters = array("parameters", 20).map((one) => {
|
|
369
|
-
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
370
|
-
return refuse("Each parameter is an object with an id and what it asks for.");
|
|
371
|
-
const row = one;
|
|
372
|
-
const id = typeof row["id"] === "string" && PARAM_ID.test(row["id"]) ? row["id"] : refuse("Each parameter needs a short id: lowercase letters, numbers and dashes.");
|
|
373
|
-
const fallback = row["default"] === undefined || row["default"] === null ? undefined : plainText(row["default"], 200, `default for ${id}`, false);
|
|
374
|
-
return { id, about: plainText(row["about"], 200, `question for ${id}`, true), ...(fallback === undefined ? {} : { default: fallback }), ...(row["optional"] === true ? { optional: true } : {}) };
|
|
375
|
-
});
|
|
376
|
-
if (new Set(parameters.map(one => one.id)).size !== parameters.length)
|
|
377
|
-
refuse("Two parameters have the same id.");
|
|
378
|
-
const rawZones = array("zones", 24);
|
|
379
|
-
if (rawZones.length === 0)
|
|
380
|
-
refuse("The file has no zones.");
|
|
381
|
-
if (rawZones.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
|
|
382
|
-
refuse("Each zone is an object with an id, a title and a kind.");
|
|
383
|
-
const zones = rawZones;
|
|
384
|
-
const triggers = array("triggers", 10).map(one => {
|
|
385
|
-
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
386
|
-
return refuse("Each trigger is an object with a kind.");
|
|
387
|
-
const kind = one["kind"];
|
|
360
|
+
// Triggers that name something on the installation they were made on stay there; say so, not that the kind is unknown.
|
|
361
|
+
(Array.isArray(input["triggers"]) ? input["triggers"] : []).forEach((one, index) => {
|
|
362
|
+
const kind = one !== null && typeof one === "object" ? one["kind"] : undefined;
|
|
388
363
|
if (kind === "chat")
|
|
389
|
-
|
|
364
|
+
refuse(`triggers[${index}].kind: a chat channel trigger can't come from a file; connect the channel from the channel itself`);
|
|
390
365
|
if (kind === "flow")
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
366
|
+
refuse(`triggers[${index}].kind: a trigger from another flow can't come from a file; it names a flow on the installation it was made on`);
|
|
367
|
+
});
|
|
368
|
+
const read = readVersioned(flowFileSchema, input, {}, FLOW_ALIASES);
|
|
369
|
+
if (!read.ok)
|
|
370
|
+
return refuse(read.issues.map(one => one.line).join("\n"));
|
|
371
|
+
const file = read.value;
|
|
372
|
+
const name = file.name.trim(), about = (file.about ?? "").trim();
|
|
373
|
+
if (name === "")
|
|
374
|
+
refuse("name: must not be empty");
|
|
375
|
+
const parameters = (file.parameters ?? []).map(one => ({ ...one, about: one.about.trim(), ...(one.default === undefined ? {} : { default: one.default.trim() }) }));
|
|
376
|
+
if (new Set(parameters.map(one => one.id)).size !== parameters.length)
|
|
377
|
+
refuse("parameters: two have the same id");
|
|
378
|
+
const triggers = file.triggers ?? [];
|
|
379
|
+
triggers.forEach((one, index) => {
|
|
394
380
|
for (const [key, value] of Object.entries(one))
|
|
395
381
|
if (typeof value === "string" && scanForSecrets(value).length > 0)
|
|
396
|
-
refuse(`
|
|
397
|
-
return one;
|
|
382
|
+
refuse(`triggers[${index}].${key}: looks like a key or password; keys never go in a flow file`);
|
|
398
383
|
});
|
|
399
|
-
const scripts =
|
|
400
|
-
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
401
|
-
return refuse("Each script is an object with a name.");
|
|
402
|
-
const row = one;
|
|
384
|
+
const scripts = (file.scripts ?? []).map((row, index) => {
|
|
403
385
|
let draft;
|
|
404
386
|
try {
|
|
405
387
|
draft = validateScript(row);
|
|
406
388
|
}
|
|
407
389
|
catch (error) {
|
|
408
|
-
return refuse(`
|
|
390
|
+
return refuse(`scripts[${index}]: ${error instanceof Error ? error.message : "it isn't valid."}`);
|
|
409
391
|
}
|
|
410
392
|
return { name: draft.name, about: draft.about, language: draft.language, timeoutMinutes: draft.timeoutMinutes, ...(draft.file === null ? { body: draft.body } : { file: draft.file }) };
|
|
411
393
|
});
|
|
412
394
|
if (new Set(scripts.map(one => one.name)).size !== scripts.length)
|
|
413
|
-
refuse("
|
|
395
|
+
refuse("scripts: two have the same name");
|
|
414
396
|
// Every {{param.x}} names a parameter the file declares.
|
|
415
|
-
for (const match of JSON.stringify([zones, triggers]).matchAll(PARAM))
|
|
397
|
+
for (const match of JSON.stringify([file.zones, triggers]).matchAll(PARAM))
|
|
416
398
|
if (!parameters.some(one => one.id === match[1]))
|
|
417
399
|
refuse(`The file uses {{param.${match[1]}}} but doesn't say what it asks for.`);
|
|
418
|
-
definitionOf(zones);
|
|
419
|
-
return { format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name, about, needs, parameters, zones, triggers, scripts };
|
|
400
|
+
definitionOf(file.zones);
|
|
401
|
+
return { format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name, about, needs: (file.needs ?? []).map(one => one.trim()), parameters, zones: file.zones, triggers, scripts };
|
|
420
402
|
}
|
|
421
403
|
/** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
|
|
422
404
|
export function parameterValues(file, given) {
|
package/dist/flow-starters.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type FlowStepInput } from "./flows.js";
|
|
2
|
+
import type { TriggerInput } from "./contracts/flow.js";
|
|
2
3
|
import type { FlowRow, Store } from "./store.js";
|
|
3
4
|
export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight", "plane-review"];
|
|
4
5
|
export type StarterId = (typeof STARTER_IDS)[number];
|
|
@@ -22,7 +23,7 @@ export type StarterFlow = {
|
|
|
22
23
|
ownSteps?: FlowStepInput[];
|
|
23
24
|
trigger: (where: {
|
|
24
25
|
branch: string;
|
|
25
|
-
}) =>
|
|
26
|
+
}) => TriggerInput;
|
|
26
27
|
};
|
|
27
28
|
export declare const STARTER_FLOWS: readonly StarterFlow[];
|
|
28
29
|
/** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
|
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -1,77 +1,12 @@
|
|
|
1
1
|
import type { Runner } from "./backend.js";
|
|
2
2
|
import { type FlowAct } from "./flow-engine.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type FlowDefinition } from "./flows.js";
|
|
4
|
+
import { type ContractResult } from "./contracts/contract.js";
|
|
5
|
+
import { FLOW_TRIGGER_KINDS, type ChatApp, type FlowTriggerKind, type TriggerConfig, type TriggerInput } from "./contracts/flow.js";
|
|
4
6
|
import { type MailReader } from "./mailbox.js";
|
|
5
7
|
import type { FlowCardSource, FlowRow, FlowTriggerRow, Store } from "./store.js";
|
|
6
|
-
export
|
|
7
|
-
export type FlowTriggerKind
|
|
8
|
-
export type TriggerConfig = {
|
|
9
|
-
kind: "button";
|
|
10
|
-
label: string;
|
|
11
|
-
questions: string[];
|
|
12
|
-
zone: string | null;
|
|
13
|
-
}
|
|
14
|
-
/** `script` (v90): run this project script on the schedule and make a card of each item it prints, instead of one card titled `title`. */
|
|
15
|
-
| {
|
|
16
|
-
kind: "schedule";
|
|
17
|
-
schedule: string;
|
|
18
|
-
title: string;
|
|
19
|
-
description: string | null;
|
|
20
|
-
zone: string | null;
|
|
21
|
-
script?: string;
|
|
22
|
-
secrets?: string[];
|
|
23
|
-
} | {
|
|
24
|
-
kind: "github";
|
|
25
|
-
repo: string;
|
|
26
|
-
watch: "issues" | "pulls" | "checks";
|
|
27
|
-
label: string | null;
|
|
28
|
-
branch: string | null;
|
|
29
|
-
from: "team" | "anyone";
|
|
30
|
-
delivery: "poll" | "webhook";
|
|
31
|
-
zone: string | null;
|
|
32
|
-
} | {
|
|
33
|
-
kind: "linear";
|
|
34
|
-
team: string | null;
|
|
35
|
-
state: string | null;
|
|
36
|
-
label: string | null;
|
|
37
|
-
delivery: "poll" | "webhook";
|
|
38
|
-
zone: string | null;
|
|
39
|
-
} | {
|
|
40
|
-
kind: "flow";
|
|
41
|
-
flow: number;
|
|
42
|
-
when: string;
|
|
43
|
-
zone: string | null;
|
|
44
|
-
} | {
|
|
45
|
-
kind: "webhook";
|
|
46
|
-
title: string;
|
|
47
|
-
titleField: string | null;
|
|
48
|
-
bodyField: string | null;
|
|
49
|
-
zone: string | null;
|
|
50
|
-
}
|
|
51
|
-
/** `sender`: addresses or domains, comma-separated; `subject`: words the subject must contain. */
|
|
52
|
-
| {
|
|
53
|
-
kind: "email";
|
|
54
|
-
folder: string;
|
|
55
|
-
sender: string | null;
|
|
56
|
-
subject: string | null;
|
|
57
|
-
zone: string | null;
|
|
58
|
-
}
|
|
59
|
-
/** A channel in a chat app; `binding` is the pairing of the person who connected it (whose chat answers there). */
|
|
60
|
-
| {
|
|
61
|
-
kind: "chat";
|
|
62
|
-
app: ChatApp;
|
|
63
|
-
installation: string;
|
|
64
|
-
chat: string;
|
|
65
|
-
binding: number;
|
|
66
|
-
zone: string | null;
|
|
67
|
-
}
|
|
68
|
-
/** `schedule`: always daily, in the routines' form ("daily:07:30@Europe/London"). */
|
|
69
|
-
| {
|
|
70
|
-
kind: "plane-review";
|
|
71
|
-
schedule: string;
|
|
72
|
-
zone: string | null;
|
|
73
|
-
};
|
|
74
|
-
export type ChatApp = "slack" | "discord" | "teams" | "telegram";
|
|
8
|
+
export { FLOW_TRIGGER_KINDS };
|
|
9
|
+
export type { ChatApp, FlowTriggerKind, TriggerConfig, TriggerInput };
|
|
75
10
|
export declare const CHAT_APP_NAMES: Record<ChatApp, string>;
|
|
76
11
|
/** What each kind is called on the canvas. */
|
|
77
12
|
export declare const FLOW_TRIGGER_WORDS: Record<FlowTriggerKind, string>;
|
|
@@ -79,11 +14,18 @@ export declare const FLOW_TRIGGER_WORDS: Record<FlowTriggerKind, string>;
|
|
|
79
14
|
export declare const POLL_EVERY_MS: number;
|
|
80
15
|
/** At most this many cards from one check or one delivery; the rest wait for the next. */
|
|
81
16
|
export declare const CARDS_PER_CHECK = 10;
|
|
17
|
+
/**
|
|
18
|
+
* A trigger's settings read by their one schema (`label: required`, `unknown key 'flows'`). A setting given as null is
|
|
19
|
+
* left out, and a button's blank question lines are dropped, as every release has read them. Then what the schema can't
|
|
20
|
+
* say, named by its path: no hidden characters and nothing that looks like a key, anywhere in the settings.
|
|
21
|
+
*/
|
|
22
|
+
export declare function readTriggerSettings(raw: unknown): ContractResult<TriggerInput>;
|
|
82
23
|
/** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in the routines' own form. */
|
|
83
24
|
export declare function scheduleFromWords(text: string): string | null;
|
|
84
25
|
/** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
|
|
85
26
|
export declare function githubRepoOf(path: string): string | null;
|
|
86
|
-
/** A trigger's settings, checked whole against the flow it starts cards in
|
|
27
|
+
/** A trigger's settings, checked whole against the flow it starts cards in (readTriggerSettings, then the flow, its zones,
|
|
28
|
+
* the project's scripts and the schedule). Throws FlowContractError, each line naming its setting. */
|
|
87
29
|
export declare function validateTriggerConfig(raw: unknown, context: {
|
|
88
30
|
store: Store;
|
|
89
31
|
flow: FlowRow;
|
|
@@ -105,6 +47,8 @@ export declare function triggerHeadline(config: TriggerConfig, store: Store): {
|
|
|
105
47
|
detail: string;
|
|
106
48
|
};
|
|
107
49
|
export declare function triggerConfigOf(trigger: FlowTriggerRow): TriggerConfig | null;
|
|
50
|
+
/** A saved trigger's config (`configJson`, which carries no version) read by its one schema, what it leaves out filled. */
|
|
51
|
+
export declare function readTriggerConfig(raw: unknown): ContractResult<TriggerConfig>;
|
|
108
52
|
/** Whether this trigger takes deliveries at a secret address rather than being checked. */
|
|
109
53
|
export declare function takesDeliveries(config: TriggerConfig): boolean;
|
|
110
54
|
/** A webhook's signing secret (GitHub: made here; Linear: pasted from Linear). 0600, beside the database, never a column. */
|
|
@@ -267,4 +211,3 @@ export type FormAnswer = {
|
|
|
267
211
|
export declare function flowFormPage(store: Store, token: string, now: Date): FormAnswer;
|
|
268
212
|
/** POST a shared form: at most one card per submission, keys refused, a limit per hour, and quiet about what a bot did wrong. */
|
|
269
213
|
export declare function receiveFlowForm(store: Store, token: string, fields: URLSearchParams, now: Date): FormAnswer;
|
|
270
|
-
export {};
|