toolroll 0.9.6 → 0.9.8
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 +64 -1
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +2 -0
- package/dist/assignment-adapters.js +2 -0
- package/dist/assignment-ui.js +3 -1
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.js +80 -16
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +28 -24
- package/dist/browser-workspace.d.ts +117 -2
- package/dist/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -0
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +11 -1
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +26 -0
- package/dist/check-levels-ui.js +84 -0
- package/dist/check-levels.d.ts +67 -0
- package/dist/check-levels.js +171 -0
- package/dist/claim.d.ts +22 -0
- package/dist/claim.js +43 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -0
- package/dist/demo-chat.js +4 -1
- package/dist/dispatch.js +9 -3
- package/dist/dispose.d.ts +12 -3
- package/dist/dispose.js +19 -5
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/export.js +3 -2
- package/dist/first-run.d.ts +34 -0
- package/dist/first-run.js +37 -1
- package/dist/flow-gallery-ui.d.ts +30 -0
- package/dist/flow-gallery-ui.js +85 -0
- package/dist/flow-gallery.d.ts +111 -0
- package/dist/flow-gallery.js +445 -0
- package/dist/flow-share.d.ts +102 -0
- package/dist/flow-share.js +614 -0
- package/dist/flow-starters.d.ts +2 -0
- package/dist/flow-starters.js +1 -1
- package/dist/flow-steps.js +7 -0
- package/dist/flow-triggers.js +2 -0
- package/dist/flows-cli.d.ts +56 -0
- package/dist/flows-cli.js +109 -3
- package/dist/flows-ui.d.ts +15 -1
- package/dist/flows-ui.js +31 -6
- package/dist/guides.js +27 -2
- package/dist/invoke.js +5 -0
- package/dist/lead-context.js +6 -2
- package/dist/mate-doors.d.ts +0 -15
- package/dist/mate-doors.js +5 -1
- package/dist/mate-tools.js +9 -4
- package/dist/models-ui.js +1 -1
- package/dist/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- package/dist/operate.d.ts +4 -1
- package/dist/operate.js +711 -98
- package/dist/process-custody.js +8 -0
- package/dist/project-concurrency.d.ts +62 -0
- package/dist/project-concurrency.js +116 -0
- package/dist/project-delete-ui.d.ts +7 -0
- package/dist/project-delete-ui.js +19 -2
- package/dist/project-delete.js +4 -0
- package/dist/provider.js +16 -0
- package/dist/pull-request-flow.d.ts +9 -0
- package/dist/pull-request-flow.js +21 -3
- package/dist/result-follow-ups.d.ts +101 -0
- package/dist/result-follow-ups.js +333 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- package/dist/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1038 -145
- package/dist/store.d.ts +112 -4
- package/dist/store.js +447 -27
- package/dist/surface.js +36 -9
- package/dist/tailnet.d.ts +11 -0
- package/dist/tailnet.js +47 -0
- package/dist/task-outcome-cli.js +2 -2
- package/dist/task-status.d.ts +13 -3
- package/dist/task-status.js +38 -16
- package/dist/team-cli.js +3 -0
- package/dist/telegram-status.js +2 -2
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +39 -6
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +4 -1
- package/dist/workspace-ui.d.ts +6 -1
- package/dist/workspace-ui.js +14 -11
- package/package.json +1 -1
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow files: a flow exported as readable JSON (*.toolroll-flow.json) and
|
|
3
|
+
* imported on another installation (docs/design/flow-gallery.md, "Export
|
|
4
|
+
* and import"; the schema is docs/flow-file.schema.json).
|
|
5
|
+
*
|
|
6
|
+
* The file speaks the lead's step vocabulary — titles, kinds, next/ifFails,
|
|
7
|
+
* instructions — so a person can read and edit it. It never carries what
|
|
8
|
+
* belongs to this installation: secrets and their values, webhook
|
|
9
|
+
* addresses and hashes, tokens, people's names, chat bindings or cards.
|
|
10
|
+
* Repository-specific values (the GitHub repository, labels, a branch,
|
|
11
|
+
* a Linear team, who decides) become parameters the import asks for.
|
|
12
|
+
*
|
|
13
|
+
* An import is previewed in plain words first, always. Its triggers arrive
|
|
14
|
+
* switched off until a person turns them on, its scripts arrive held until
|
|
15
|
+
* a person approves them, and its instructions are shown as the untrusted
|
|
16
|
+
* text they are.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { scanForSecrets } from "./evidence.js";
|
|
20
|
+
import { flowDefinitionOf } from "./flow-engine.js";
|
|
21
|
+
import { readFlowSecrets } from "./flow-secrets.js";
|
|
22
|
+
import { scriptDigest, validateScript } from "./flow-scripts.js";
|
|
23
|
+
import { addFlowTriggerTo, describeTrigger, HOOK_PATH, readHooksBase, triggerConfigOf, validateTriggerConfig } from "./flow-triggers.js";
|
|
24
|
+
import { durationMinutes, durationWords, flowTerms, LANGUAGE_WORDS, validateFlowDefinition } from "./flows.js";
|
|
25
|
+
import { parseSchedule } from "./routine.js";
|
|
26
|
+
export const FLOW_FILE_FORMAT = "toolroll-flow";
|
|
27
|
+
export const FLOW_FILE_VERSION = 1;
|
|
28
|
+
/** The largest flow file Toolroll reads or fetches. */
|
|
29
|
+
export const FLOW_FILE_MAX_BYTES = 256 * 1024;
|
|
30
|
+
export const FLOW_FILE_SUFFIX = ".toolroll-flow.json";
|
|
31
|
+
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
|
+
const REMOVED = "[removed]";
|
|
34
|
+
// ------------------------------------------------------------------ export
|
|
35
|
+
/** A zone in the lead's words: only what it says, in the order a person reads it. */
|
|
36
|
+
function stepOf(stage, ask) {
|
|
37
|
+
const step = { id: stage.id, title: stage.title, kind: stage.kind };
|
|
38
|
+
const words = (minutes) => durationMinutes(durationWords(minutes)) === minutes ? durationWords(minutes) : minutes;
|
|
39
|
+
if (stage.instructions !== null)
|
|
40
|
+
step["instructions"] = stage.instructions;
|
|
41
|
+
if (stage.kind === "task")
|
|
42
|
+
step["planning"] = stage.planning;
|
|
43
|
+
if (stage.kind === "approval")
|
|
44
|
+
step["decider"] = stage.toOwner === true ? "owner" : stage.approver === null ? "anyone"
|
|
45
|
+
: 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
|
+
step["message"] = stage.message;
|
|
48
|
+
if (stage.kind === "update")
|
|
49
|
+
step["close"] = stage.close;
|
|
50
|
+
if (stage.kind === "check") {
|
|
51
|
+
step["script"] = stage.script;
|
|
52
|
+
if (stage.runIn !== undefined)
|
|
53
|
+
step["runIn"] = stage.runIn;
|
|
54
|
+
if (stage.secrets !== undefined)
|
|
55
|
+
step["secrets"] = stage.secrets;
|
|
56
|
+
}
|
|
57
|
+
if (stage.routes !== undefined)
|
|
58
|
+
step["routes"] = stage.routes.map(one => ({ answer: one.answer, goesTo: one.to }));
|
|
59
|
+
if (stage.sort !== null) {
|
|
60
|
+
step["question"] = stage.sort.question;
|
|
61
|
+
step["answers"] = stage.sort.answers.map(one => ({ answer: one.answer, means: one.means, goesTo: one.to }));
|
|
62
|
+
step["sureAt"] = Math.round(stage.sort.sureAt * 100);
|
|
63
|
+
if (stage.sort.notes.length > 0)
|
|
64
|
+
step["alsoNote"] = stage.sort.notes.map(one => ({ id: one.id, question: one.question, kind: one.kind, ...(one.levels === null ? {} : { levels: one.levels }) }));
|
|
65
|
+
}
|
|
66
|
+
if (stage.request !== undefined)
|
|
67
|
+
Object.assign(step, { method: stage.request.method, url: stage.request.url, headers: stage.request.headers, ...(stage.request.body === null ? {} : { body: stage.request.body }) });
|
|
68
|
+
if (stage.email !== undefined) {
|
|
69
|
+
// A written-out address is someone's: the import asks for it. Fill-ins ({{card.email}}) stay.
|
|
70
|
+
const to = /[^\s@{}]+@[^\s@{}]+/.test(stage.email.to) ? ask({ id: `email-to-${stage.id}`.slice(0, 40), about: `Who ${stage.title} emails` }) : stage.email.to;
|
|
71
|
+
Object.assign(step, { to, subject: stage.email.subject, body: stage.email.body });
|
|
72
|
+
}
|
|
73
|
+
if (stage.tool !== undefined)
|
|
74
|
+
Object.assign(step, { server: stage.tool.server, tool: stage.tool.name, args: stage.tool.args });
|
|
75
|
+
if (stage.wait !== undefined)
|
|
76
|
+
Object.assign(step, { waitFor: stage.wait.for,
|
|
77
|
+
...(stage.wait.for === "hours" ? { from: stage.wait.from, until: stage.wait.to, ...(stage.wait.timeZone === undefined ? {} : { timeZone: stage.wait.timeZone }) } : { wait: words(stage.wait.minutes) }) });
|
|
78
|
+
if (stage.merge !== undefined)
|
|
79
|
+
step["merge"] = stage.merge;
|
|
80
|
+
if (stage.teammate !== undefined)
|
|
81
|
+
step["teammate"] = stage.teammate;
|
|
82
|
+
if (stage.reply === true)
|
|
83
|
+
step["reply"] = true;
|
|
84
|
+
if (stage.limit !== undefined)
|
|
85
|
+
Object.assign(step, { remindAfter: words(stage.limit.minutes), ...(stage.limit.to === null ? {} : { thenMoveTo: stage.limit.to }) });
|
|
86
|
+
if (stage.next !== null)
|
|
87
|
+
step["next"] = stage.next;
|
|
88
|
+
if (stage.onFail !== null)
|
|
89
|
+
step[stage.kind === "sort" ? "ifNotSure" : stage.kind === "wait" ? "ifNoReply" : "ifFails"] = stage.onFail;
|
|
90
|
+
step["at"] = { x: stage.zone.x, y: stage.zone.y, w: stage.zone.w, h: stage.zone.h, color: stage.zone.color };
|
|
91
|
+
return step;
|
|
92
|
+
}
|
|
93
|
+
/** A trigger's settings for the file, or why it stays behind. */
|
|
94
|
+
function triggerOf(config, ask) {
|
|
95
|
+
const zone = config.zone === null ? {} : { zone: config.zone };
|
|
96
|
+
switch (config.kind) {
|
|
97
|
+
case "button": return { kind: "button", label: config.label, questions: config.questions, ...zone };
|
|
98
|
+
case "schedule": return { kind: "schedule", schedule: config.schedule, ...(config.script !== undefined ? { script: config.script, ...(config.secrets === undefined ? {} : { secrets: config.secrets }) } : { title: config.title, ...(config.description === null ? {} : { description: config.description }) }), ...zone };
|
|
99
|
+
case "github": return { kind: "github", watch: config.watch,
|
|
100
|
+
repo: ask({ id: "github-repo", about: "The GitHub repository, as owner/name (left empty: the project's own)", optional: true }),
|
|
101
|
+
...(config.label === null ? {} : { label: ask({ id: "github-label", about: `The label ${config.watch === "pulls" ? "pull requests" : "issues"} need`, default: config.label }) }),
|
|
102
|
+
...(config.branch === null ? {} : { branch: ask({ id: "github-branch", about: "The branch whose failed checks start cards", default: config.branch }) }),
|
|
103
|
+
from: config.from, delivery: config.delivery, ...zone };
|
|
104
|
+
case "linear": return { kind: "linear",
|
|
105
|
+
...(config.team === null ? {} : { team: ask({ id: "linear-team", about: "The Linear team's short key, like ENG", default: config.team }) }),
|
|
106
|
+
...(config.state === null ? {} : { state: config.state }),
|
|
107
|
+
...(config.label === null ? {} : { label: ask({ id: "linear-label", about: "The Linear label issues need", default: config.label }) }),
|
|
108
|
+
delivery: config.delivery, ...zone };
|
|
109
|
+
case "webhook": return { kind: "webhook", title: config.title, ...(config.titleField === null ? {} : { titleField: config.titleField }), ...(config.bodyField === null ? {} : { bodyField: config.bodyField }), ...zone };
|
|
110
|
+
case "email": return { kind: "email", folder: config.folder,
|
|
111
|
+
...(config.sender === null ? {} : { sender: ask({ id: "email-sender", about: "Whose mail starts cards: addresses or domains, comma-separated (left empty: anyone's)", optional: true }) }),
|
|
112
|
+
...(config.subject === null ? {} : { subject: config.subject }), ...zone };
|
|
113
|
+
case "plane-review": {
|
|
114
|
+
const schedule = parseSchedule(config.schedule);
|
|
115
|
+
return { kind: "plane-review", at: schedule !== null && schedule.kind !== "every" ? schedule.hhmm : "07:30", timeZone: schedule !== null && schedule.kind !== "every" ? schedule.timezone ?? "UTC" : "UTC", ...zone };
|
|
116
|
+
}
|
|
117
|
+
case "chat": return `the ${config.app} channel trigger (connect a channel from the channel itself)`;
|
|
118
|
+
case "flow": return "the trigger from another flow (it names a flow on this installation)";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** What a flow needs to run, as short keys the import puts in words. */
|
|
122
|
+
function needsOf(definition, triggers, scripts) {
|
|
123
|
+
const needs = new Set();
|
|
124
|
+
for (const stage of definition.stages) {
|
|
125
|
+
if (stage.kind === "pull-request" || stage.kind === "update")
|
|
126
|
+
needs.add("github");
|
|
127
|
+
if (stage.kind === "sort")
|
|
128
|
+
needs.add("openrouter");
|
|
129
|
+
if (stage.kind === "email" || (stage.kind === "wait" && stage.wait?.for === "reply"))
|
|
130
|
+
needs.add("email");
|
|
131
|
+
if (stage.tool !== undefined)
|
|
132
|
+
needs.add(`tool:${stage.tool.server}`);
|
|
133
|
+
if (stage.teammate !== undefined)
|
|
134
|
+
needs.add(`teammate:${stage.teammate}`);
|
|
135
|
+
for (const name of stage.secrets ?? [])
|
|
136
|
+
needs.add(`secret:${name}`);
|
|
137
|
+
for (const value of Object.values(stage.request?.headers ?? {}))
|
|
138
|
+
for (const match of value.matchAll(/\{\{\s*secret\.([A-Z][A-Z0-9_]{0,39})\s*\}\}/g))
|
|
139
|
+
needs.add(`secret:${match[1]}`);
|
|
140
|
+
}
|
|
141
|
+
for (const trigger of triggers) {
|
|
142
|
+
if (trigger["kind"] === "github")
|
|
143
|
+
needs.add("github");
|
|
144
|
+
if (trigger["kind"] === "linear")
|
|
145
|
+
needs.add("linear");
|
|
146
|
+
if (trigger["kind"] === "email")
|
|
147
|
+
needs.add("email");
|
|
148
|
+
for (const name of Array.isArray(trigger["secrets"]) ? trigger["secrets"] : [])
|
|
149
|
+
needs.add(`secret:${name}`);
|
|
150
|
+
}
|
|
151
|
+
for (const name of scripts)
|
|
152
|
+
needs.add(`script:${name}`);
|
|
153
|
+
return [...needs].sort();
|
|
154
|
+
}
|
|
155
|
+
/** Everything that would name this installation or someone on it, to take out of the file's text. */
|
|
156
|
+
function privateValues(store, flow, definition, dir) {
|
|
157
|
+
const values = Object.values(readFlowSecrets(dir, flow.repo)).filter(value => value.length >= 6);
|
|
158
|
+
const base = readHooksBase(dir);
|
|
159
|
+
if (base !== null)
|
|
160
|
+
values.push(base);
|
|
161
|
+
const names = new Set([flow.owner, flow.createdBy, flow.updatedBy, ...store.accountFacts().map(one => one.name)]);
|
|
162
|
+
for (const stage of definition.stages)
|
|
163
|
+
if (stage.approver !== null)
|
|
164
|
+
names.add(stage.approver);
|
|
165
|
+
for (const trigger of store.flowTriggers(flow.id))
|
|
166
|
+
names.add(trigger.createdBy);
|
|
167
|
+
for (const script of store.flowScripts(flow.repo))
|
|
168
|
+
names.add(script.savedBy);
|
|
169
|
+
return { values, names: [...names].filter(name => name.trim().length >= 3) };
|
|
170
|
+
}
|
|
171
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
172
|
+
/** Text with secrets, hook addresses and people's names taken out. A line that looks like a key goes whole. */
|
|
173
|
+
function scrubber(values, names) {
|
|
174
|
+
const people = names.length === 0 ? null : new RegExp(`(?<![A-Za-z0-9_.@-])(?:${names.map(escapeRegExp).join("|")})(?![A-Za-z0-9_@-])`, "gi");
|
|
175
|
+
return text => {
|
|
176
|
+
let out = values.reduce((acc, value) => acc.split(value).join(REMOVED), text);
|
|
177
|
+
out = out.replace(new RegExp(`${escapeRegExp(HOOK_PATH)}[A-Za-z0-9_-]+`, "g"), REMOVED);
|
|
178
|
+
if (people !== null)
|
|
179
|
+
out = out.replace(people, "[a person]");
|
|
180
|
+
return out.split("\n").map(line => scanForSecrets(line).length > 0 ? REMOVED : line).join("\n");
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Structural fields: ids and references, never prose. Scrubbing them would break the flow's paths. */
|
|
184
|
+
const STRUCTURAL = new Set(["format", "id", "kind", "goesTo", "next", "ifFails", "ifNotSure", "ifNoReply", "thenMoveTo", "zone", "script", "language", "planning", "runIn", "waitFor", "method", "watch", "from", "delivery", "color", "merge"]);
|
|
185
|
+
function scrubDeep(value, scrub, key, inScripts) {
|
|
186
|
+
if (typeof value === "string")
|
|
187
|
+
return key !== null && (STRUCTURAL.has(key) || (inScripts && key === "name")) ? value : scrub(value);
|
|
188
|
+
if (Array.isArray(value))
|
|
189
|
+
return value.map(one => scrubDeep(one, scrub, key, inScripts));
|
|
190
|
+
if (value !== null && typeof value === "object")
|
|
191
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, scrub, k, inScripts || k === "scripts")]));
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
/** A flow as a file: its zones and paths, its triggers' settings and the scripts it runs, nothing of this installation's. */
|
|
195
|
+
export function exportFlow(store, flow, dir) {
|
|
196
|
+
const definition = flowDefinitionOf(flow);
|
|
197
|
+
if (definition === null)
|
|
198
|
+
throw new Error("This flow's drawing can't be read. Save it again from the editor.");
|
|
199
|
+
const parameters = [];
|
|
200
|
+
const ask = (parameter) => {
|
|
201
|
+
let id = parameter.id;
|
|
202
|
+
for (let n = 2; parameters.some(one => one.id === id); n++)
|
|
203
|
+
id = `${parameter.id.slice(0, 36)}-${n}`;
|
|
204
|
+
parameters.push({ ...parameter, id });
|
|
205
|
+
return `{{param.${id}}}`;
|
|
206
|
+
};
|
|
207
|
+
// The start goes first, so the file reads in order and the import starts where this flow does.
|
|
208
|
+
const ordered = [...definition.stages.filter(one => one.id === definition.start), ...definition.stages.filter(one => one.id !== definition.start)];
|
|
209
|
+
const zones = ordered.map(stage => stepOf(stage, ask));
|
|
210
|
+
const left = [];
|
|
211
|
+
const triggers = [];
|
|
212
|
+
for (const row of store.flowTriggers(flow.id).filter(one => one.state !== "removed")) {
|
|
213
|
+
const config = triggerConfigOf(row);
|
|
214
|
+
if (config === null) {
|
|
215
|
+
left.push("a trigger that can't be read");
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const made = triggerOf(config, ask);
|
|
219
|
+
if (typeof made === "string")
|
|
220
|
+
left.push(made);
|
|
221
|
+
else
|
|
222
|
+
triggers.push(made);
|
|
223
|
+
}
|
|
224
|
+
const wanted = [...new Set([...definition.stages.flatMap(one => one.kind === "check" && one.script !== null ? [one.script] : []), ...triggers.flatMap(one => typeof one["script"] === "string" ? [one["script"]] : [])])].sort();
|
|
225
|
+
const scripts = [];
|
|
226
|
+
for (const name of wanted) {
|
|
227
|
+
const script = store.flowScript(flow.repo, name);
|
|
228
|
+
if (script === null) {
|
|
229
|
+
left.push(`the ${name} script (this project doesn't have it)`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
scripts.push({ name: script.name, about: script.about, language: script.language, timeoutMinutes: script.timeoutMinutes, ...(script.file === null ? { body: script.body } : { file: script.file }) });
|
|
233
|
+
}
|
|
234
|
+
const { values, names } = privateValues(store, flow, definition, dir);
|
|
235
|
+
const raw = {
|
|
236
|
+
format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name: flow.name,
|
|
237
|
+
about: ordered.map(one => one.title).join(" → "),
|
|
238
|
+
needs: needsOf(definition, triggers, scripts.map(one => one.name)),
|
|
239
|
+
parameters, zones, triggers, scripts,
|
|
240
|
+
};
|
|
241
|
+
const file = scrubDeep(raw, scrubber(values, names), null, false);
|
|
242
|
+
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "flow";
|
|
243
|
+
return { file, json: `${JSON.stringify(file, null, 2)}\n`, fileName: `${slug}${FLOW_FILE_SUFFIX}`, left };
|
|
244
|
+
}
|
|
245
|
+
// ------------------------------------------------------------------ reading
|
|
246
|
+
export class FlowFileError extends Error {
|
|
247
|
+
}
|
|
248
|
+
const refuse = (message) => { throw new FlowFileError(message); };
|
|
249
|
+
/** A zone from the file as the canvas stores it: references by id (or by title), the rest checked by validateFlowDefinition. */
|
|
250
|
+
function stageInputOf(step, index, find) {
|
|
251
|
+
const title = typeof step["title"] === "string" ? step["title"] : `zone ${index + 1}`;
|
|
252
|
+
const kind = step["kind"];
|
|
253
|
+
const object = (key) => {
|
|
254
|
+
const value = step[key];
|
|
255
|
+
if (value === undefined || value === null)
|
|
256
|
+
return undefined;
|
|
257
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
258
|
+
refuse(`Zone ${title}: ${key} must be an object.`);
|
|
259
|
+
return value;
|
|
260
|
+
};
|
|
261
|
+
const list = (key) => {
|
|
262
|
+
const value = step[key];
|
|
263
|
+
if (value === undefined || value === null)
|
|
264
|
+
return undefined;
|
|
265
|
+
if (!Array.isArray(value) || value.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
|
|
266
|
+
refuse(`Zone ${title}: ${key} must be a list.`);
|
|
267
|
+
return value;
|
|
268
|
+
};
|
|
269
|
+
const decider = step["decider"];
|
|
270
|
+
if (decider !== undefined && decider !== null && typeof decider !== "string")
|
|
271
|
+
refuse(`Zone ${title}: decider must be plain text.`);
|
|
272
|
+
const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
|
|
273
|
+
const anyone = decider === undefined || decider === null || (typeof decider === "string" && /^(anyone|any approver|)$/i.test(decider.trim()));
|
|
274
|
+
const at = object("at") ?? {};
|
|
275
|
+
const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifFails"];
|
|
276
|
+
const minutes = (value) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`Zone ${title}: say a time like "3 days" (up to 30 days).`);
|
|
277
|
+
const sureAt = step["sureAt"];
|
|
278
|
+
return {
|
|
279
|
+
id: step["id"], title: step["title"], kind,
|
|
280
|
+
zone: { x: at["x"], y: at["y"] ?? 0, w: at["w"], h: at["h"], color: at["color"] },
|
|
281
|
+
instructions: step["instructions"], planning: step["planning"],
|
|
282
|
+
...(toOwner ? { toOwner: true } : { approver: anyone ? null : decider }),
|
|
283
|
+
message: step["message"], close: step["close"], script: step["script"], runIn: step["runIn"],
|
|
284
|
+
routes: list("routes")?.map(one => ({ answer: one["answer"], to: find(one["goesTo"], title) })),
|
|
285
|
+
secrets: step["secrets"],
|
|
286
|
+
...(kind === "sort" ? { sort: { question: step["question"], answers: (list("answers") ?? []).map(one => ({ answer: one["answer"], means: one["means"], to: find(one["goesTo"], title) })),
|
|
287
|
+
sureAt: typeof sureAt === "number" && sureAt > 1 ? sureAt / 100 : sureAt, notes: list("alsoNote") ?? [] } } : {}),
|
|
288
|
+
...(kind === "request" ? { request: { method: step["method"], url: step["url"], headers: object("headers") ?? {}, body: step["body"] } } : {}),
|
|
289
|
+
...(kind === "email" ? { email: { to: step["to"], subject: step["subject"], body: step["body"] } } : {}),
|
|
290
|
+
...(kind === "tool" ? { tool: { server: step["server"], name: step["tool"], args: typeof step["args"] === "object" && step["args"] !== null ? JSON.stringify(step["args"]) : step["args"] } } : {}),
|
|
291
|
+
...(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") } } : {}),
|
|
292
|
+
merge: step["merge"], teammate: step["teammate"], reply: step["reply"],
|
|
293
|
+
...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"]), to: find(step["thenMoveTo"], title) } }),
|
|
294
|
+
next: find(step["next"], title), onFail: find(failKey, title),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/** The zones as a flow, checked whole. Throws in plain words. */
|
|
298
|
+
function definitionOf(zones) {
|
|
299
|
+
const ids = zones.map(one => one["id"]);
|
|
300
|
+
const find = (ref, from) => {
|
|
301
|
+
if (ref === undefined || ref === null || ref === "")
|
|
302
|
+
return null;
|
|
303
|
+
if (typeof ref !== "string")
|
|
304
|
+
return refuse(`Zone ${from}: a path names a zone by its id.`);
|
|
305
|
+
const hit = zones.find(one => one["id"] === ref.trim()) ?? zones.find(one => typeof one["title"] === "string" && one["title"].trim().toLowerCase() === ref.trim().toLowerCase());
|
|
306
|
+
if (hit === undefined)
|
|
307
|
+
return refuse(`Zone ${from}: there's no zone called ${ref}.`);
|
|
308
|
+
return typeof hit["id"] === "string" ? hit["id"] : refuse(`Zone ${from}: every zone needs an id.`);
|
|
309
|
+
};
|
|
310
|
+
if (ids.some(one => typeof one !== "string"))
|
|
311
|
+
refuse("Every zone needs an id: lowercase letters, numbers and dashes.");
|
|
312
|
+
const stages = zones.map((step, index) => stageInputOf(step, index, find));
|
|
313
|
+
try {
|
|
314
|
+
return validateFlowDefinition({ version: 1, start: stages[0]?.["id"], stages });
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return refuse(error instanceof Error ? error.message : "Those zones aren't a flow.");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const plainText = (value, cap, what, required) => {
|
|
321
|
+
if (value === undefined || value === null || value === "")
|
|
322
|
+
return required ? refuse(`The file has no ${what}.`) : "";
|
|
323
|
+
if (typeof value !== "string")
|
|
324
|
+
return refuse(`The file's ${what} must be plain text.`);
|
|
325
|
+
if (value.length > cap)
|
|
326
|
+
return refuse(`The file's ${what} is longer than ${cap} characters.`);
|
|
327
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f--]/.test(value))
|
|
328
|
+
return refuse(`The file's ${what} has hidden characters in it.`);
|
|
329
|
+
return value.trim();
|
|
330
|
+
};
|
|
331
|
+
/** Read a flow file: its size, its JSON, its format and version, and every zone, trigger, script and parameter. Throws FlowFileError in plain words. */
|
|
332
|
+
export function parseFlowFile(text) {
|
|
333
|
+
if (Buffer.byteLength(text, "utf8") > FLOW_FILE_MAX_BYTES)
|
|
334
|
+
refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
|
|
335
|
+
let raw;
|
|
336
|
+
try {
|
|
337
|
+
raw = JSON.parse(text.replace(/^/, ""));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return refuse("That isn't a flow file: it isn't valid JSON.");
|
|
341
|
+
}
|
|
342
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
343
|
+
refuse("That isn't a flow file: it should be one JSON object.");
|
|
344
|
+
const input = raw;
|
|
345
|
+
if (input["format"] !== FLOW_FILE_FORMAT)
|
|
346
|
+
refuse("That isn't a Toolroll flow file: its format isn't \"toolroll-flow\".");
|
|
347
|
+
if (input["version"] !== FLOW_FILE_VERSION)
|
|
348
|
+
refuse(typeof input["version"] === "number" && input["version"] > FLOW_FILE_VERSION
|
|
349
|
+
? `This flow file is version ${input["version"]}; this Toolroll reads version ${FLOW_FILE_VERSION}. Update Toolroll, then import it.`
|
|
350
|
+
: `This flow file has no version Toolroll reads; it should say "version": ${FLOW_FILE_VERSION}.`);
|
|
351
|
+
const name = plainText(input["name"], 80, "name", true);
|
|
352
|
+
const about = plainText(input["about"], 600, "about line", false);
|
|
353
|
+
const array = (key, cap) => {
|
|
354
|
+
const value = input[key];
|
|
355
|
+
if (value === undefined || value === null)
|
|
356
|
+
return [];
|
|
357
|
+
if (!Array.isArray(value))
|
|
358
|
+
return refuse(`The file's ${key} must be a list.`);
|
|
359
|
+
if (value.length > cap)
|
|
360
|
+
return refuse(`The file has more than ${cap} ${key}.`);
|
|
361
|
+
return value;
|
|
362
|
+
};
|
|
363
|
+
const needs = array("needs", 40).map(one => plainText(one, 80, "needs", true));
|
|
364
|
+
const parameters = array("parameters", 20).map((one) => {
|
|
365
|
+
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
366
|
+
return refuse("Each parameter is an object with an id and what it asks for.");
|
|
367
|
+
const row = one;
|
|
368
|
+
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.");
|
|
369
|
+
const fallback = row["default"] === undefined || row["default"] === null ? undefined : plainText(row["default"], 200, `default for ${id}`, false);
|
|
370
|
+
return { id, about: plainText(row["about"], 200, `question for ${id}`, true), ...(fallback === undefined ? {} : { default: fallback }), ...(row["optional"] === true ? { optional: true } : {}) };
|
|
371
|
+
});
|
|
372
|
+
if (new Set(parameters.map(one => one.id)).size !== parameters.length)
|
|
373
|
+
refuse("Two parameters have the same id.");
|
|
374
|
+
const rawZones = array("zones", 24);
|
|
375
|
+
if (rawZones.length === 0)
|
|
376
|
+
refuse("The file has no zones.");
|
|
377
|
+
if (rawZones.some(one => one === null || typeof one !== "object" || Array.isArray(one)))
|
|
378
|
+
refuse("Each zone is an object with an id, a title and a kind.");
|
|
379
|
+
const zones = rawZones;
|
|
380
|
+
const triggers = array("triggers", 10).map(one => {
|
|
381
|
+
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
382
|
+
return refuse("Each trigger is an object with a kind.");
|
|
383
|
+
const kind = one["kind"];
|
|
384
|
+
if (kind === "chat")
|
|
385
|
+
return refuse("A chat channel trigger can't come from a file: connect the channel from the channel itself.");
|
|
386
|
+
if (kind === "flow")
|
|
387
|
+
return refuse("A trigger from another flow can't come from a file: it names a flow on the installation it was made on.");
|
|
388
|
+
if (!["button", "schedule", "github", "linear", "webhook", "email", "plane-review"].includes(kind))
|
|
389
|
+
return refuse(`The file has a trigger Toolroll doesn't know: ${String(kind)}.`);
|
|
390
|
+
for (const [key, value] of Object.entries(one))
|
|
391
|
+
if (typeof value === "string" && scanForSecrets(value).length > 0)
|
|
392
|
+
refuse(`A ${String(kind)} trigger's ${key} looks like a key or password. Keys never go in a flow file.`);
|
|
393
|
+
return one;
|
|
394
|
+
});
|
|
395
|
+
const scripts = array("scripts", 20).map((one) => {
|
|
396
|
+
if (one === null || typeof one !== "object" || Array.isArray(one))
|
|
397
|
+
return refuse("Each script is an object with a name.");
|
|
398
|
+
const row = one;
|
|
399
|
+
let draft;
|
|
400
|
+
try {
|
|
401
|
+
draft = validateScript(row);
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
return refuse(`Script ${typeof row["name"] === "string" ? row["name"] : "(unnamed)"}: ${error instanceof Error ? error.message : "it isn't valid."}`);
|
|
405
|
+
}
|
|
406
|
+
return { name: draft.name, about: draft.about, language: draft.language, timeoutMinutes: draft.timeoutMinutes, ...(draft.file === null ? { body: draft.body } : { file: draft.file }) };
|
|
407
|
+
});
|
|
408
|
+
if (new Set(scripts.map(one => one.name)).size !== scripts.length)
|
|
409
|
+
refuse("Two scripts have the same name.");
|
|
410
|
+
// Every {{param.x}} names a parameter the file declares.
|
|
411
|
+
for (const match of JSON.stringify([zones, triggers]).matchAll(PARAM))
|
|
412
|
+
if (!parameters.some(one => one.id === match[1]))
|
|
413
|
+
refuse(`The file uses {{param.${match[1]}}} but doesn't say what it asks for.`);
|
|
414
|
+
definitionOf(zones);
|
|
415
|
+
return { format: FLOW_FILE_FORMAT, version: FLOW_FILE_VERSION, name, about, needs, parameters, zones, triggers, scripts };
|
|
416
|
+
}
|
|
417
|
+
/** --param k=v lines (or a form's fields) as values; refuses parameters the file doesn't ask for. */
|
|
418
|
+
export function parameterValues(file, given) {
|
|
419
|
+
const values = {};
|
|
420
|
+
for (const [id, value] of Object.entries(given)) {
|
|
421
|
+
if (!file.parameters.some(one => one.id === id))
|
|
422
|
+
refuse(`This flow doesn't ask for ${id}. It asks for: ${file.parameters.map(one => one.id).join(", ") || "nothing"}.`);
|
|
423
|
+
values[id] = plainText(value, 200, id, false);
|
|
424
|
+
}
|
|
425
|
+
for (const parameter of file.parameters) {
|
|
426
|
+
if (values[parameter.id] !== undefined && (values[parameter.id] !== "" || parameter.optional === true))
|
|
427
|
+
continue;
|
|
428
|
+
if (parameter.default !== undefined)
|
|
429
|
+
values[parameter.id] = parameter.default;
|
|
430
|
+
else if (parameter.optional === true)
|
|
431
|
+
values[parameter.id] = "";
|
|
432
|
+
else
|
|
433
|
+
refuse(`This flow asks for ${parameter.id}: ${parameter.about}. Give it with --param ${parameter.id}=<value>.`);
|
|
434
|
+
}
|
|
435
|
+
// In the file's order, so the same answers always read (and fingerprint) the same.
|
|
436
|
+
return Object.fromEntries(file.parameters.map(one => [one.id, values[one.id]]));
|
|
437
|
+
}
|
|
438
|
+
function fill(value, values) {
|
|
439
|
+
if (typeof value === "string") {
|
|
440
|
+
const filled = value.replace(PARAM, (_whole, id) => values[id] ?? "");
|
|
441
|
+
return filled.trim() === "" && value.trim() !== "" ? null : filled;
|
|
442
|
+
}
|
|
443
|
+
if (Array.isArray(value))
|
|
444
|
+
return value.map(one => fill(one, values));
|
|
445
|
+
if (value !== null && typeof value === "object")
|
|
446
|
+
return Object.fromEntries(Object.entries(value).map(([key, one]) => [key, fill(one, values)]));
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
const NEED_WORDS = {
|
|
450
|
+
github: "GitHub, through the gh command signed in on this computer", linear: "A Linear key", openrouter: "An OpenRouter key, for its Sort zones", email: "Email, set up in Settings → Email",
|
|
451
|
+
};
|
|
452
|
+
export function needWords(need) {
|
|
453
|
+
const [kind, name] = need.includes(":") ? [need.slice(0, need.indexOf(":")), need.slice(need.indexOf(":") + 1)] : [need, ""];
|
|
454
|
+
return NEED_WORDS[need] ?? (kind === "secret" ? `A saved secret called ${name}` : kind === "tool" ? `The ${name} tool (Settings → Tools)` : kind === "teammate" ? `An AI teammate called ${name}` : kind === "script" ? `The ${name} script` : need);
|
|
455
|
+
}
|
|
456
|
+
/** The import in plain words, checked against the project it lands in. Throws FlowFileError. */
|
|
457
|
+
export function planFlowImport(store, repo, file, given, actor) {
|
|
458
|
+
const values = parameterValues(file, given);
|
|
459
|
+
const zones = fill(file.zones, values);
|
|
460
|
+
const definition = definitionOf(zones);
|
|
461
|
+
const people = new Set(store.accountFacts().filter(one => one.revokedAt === null).map(one => one.name));
|
|
462
|
+
for (const stage of definition.stages)
|
|
463
|
+
if (stage.approver !== null && !people.has(stage.approver))
|
|
464
|
+
refuse(`${stage.title} is decided by ${stage.approver}, who isn't anyone here. Give a sign-in name, owner or anyone.`);
|
|
465
|
+
const add = [], kept = [];
|
|
466
|
+
for (const script of file.scripts) {
|
|
467
|
+
const current = store.flowScript(repo, script.name);
|
|
468
|
+
if (current === null) {
|
|
469
|
+
add.push(script);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const draft = { body: script.body ?? "", timeoutMinutes: script.timeoutMinutes, language: script.language, file: script.file ?? null };
|
|
473
|
+
if (current.digest !== scriptDigest(draft))
|
|
474
|
+
refuse(`This project already has a different script called ${script.name}. Rename it in the file (and in the zones that run it), then import.`);
|
|
475
|
+
kept.push(script.name);
|
|
476
|
+
}
|
|
477
|
+
const named = new Set([...file.scripts.map(one => one.name)]);
|
|
478
|
+
for (const stage of definition.stages)
|
|
479
|
+
if (stage.kind === "check" && stage.script !== null && !named.has(stage.script) && store.flowScript(repo, stage.script) === null)
|
|
480
|
+
refuse(`${stage.title} runs a script called ${stage.script}, which isn't in the file or this project.`);
|
|
481
|
+
const draft = { id: 0, repo, name: file.name, definitionJson: JSON.stringify(definition), revision: 1, state: "active", createdBy: actor, createdAt: "", updatedBy: actor, updatedAt: "", owner: actor };
|
|
482
|
+
const triggers = fill(file.triggers, values).map(one => Object.fromEntries(Object.entries(one).filter(([, value]) => value !== null)));
|
|
483
|
+
const triggerWords = triggers.map(trigger => {
|
|
484
|
+
const script = typeof trigger["script"] === "string" ? trigger["script"] : null;
|
|
485
|
+
if (script !== null && !named.has(script) && store.flowScript(repo, script) === null)
|
|
486
|
+
refuse(`A schedule runs a script called ${script}, which isn't in the file or this project.`);
|
|
487
|
+
// A script the import brings isn't saved yet: its schedule is checked without it, then said with it.
|
|
488
|
+
let config;
|
|
489
|
+
try {
|
|
490
|
+
config = validateTriggerConfig(script !== null && store.flowScript(repo, script) === null ? { ...trigger, script: undefined, title: `Items from ${script}` } : trigger, { store, flow: draft, definition, actor });
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
return refuse(`A ${String(trigger["kind"])} trigger: ${error instanceof Error ? error.message : "it isn't valid."}`);
|
|
494
|
+
}
|
|
495
|
+
if (script !== null && config.kind === "schedule")
|
|
496
|
+
config = { ...config, script, title: `Items from ${script}` };
|
|
497
|
+
const zone = definition.stages.find(one => one.id === (config.zone ?? definition.start))?.title ?? "the first zone";
|
|
498
|
+
return `${describeTrigger(config, store)} → ${zone}. Arrives switched off until you turn it on.${config.kind === "webhook" || ("delivery" in config && config.delivery === "webhook") ? " It gets a new address when you make one on its Triggers panel." : ""}`;
|
|
499
|
+
});
|
|
500
|
+
const project = repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
|
|
501
|
+
const all = flowTerms(definition, null);
|
|
502
|
+
// An about line that only lists the zones says nothing the steps don't.
|
|
503
|
+
const about = file.about === "" || file.about === definition.stages.map(one => one.title).join(" → ") ? null : `About, from the file: ${file.about}`;
|
|
504
|
+
// Scripts have their own lines; the rest of what it needs is said once.
|
|
505
|
+
const needs = file.needs.filter(one => !one.startsWith("script:"));
|
|
506
|
+
const parts = {
|
|
507
|
+
about,
|
|
508
|
+
steps: all.slice(0, definition.stages.length), notes: all.slice(definition.stages.length),
|
|
509
|
+
triggers: triggerWords,
|
|
510
|
+
scripts: add.map(one => `Adds the ${one.name} script (${LANGUAGE_WORDS[one.language]}${one.file === undefined ? "" : `, runs ${one.file}`}): ${one.about}\nIt can't run until you approve it on the Scripts panel.`),
|
|
511
|
+
kept: kept.map(one => `Uses this project's ${one} script, which is the same.`),
|
|
512
|
+
filled: Object.keys(values).length === 0 ? null : `Filled in: ${file.parameters.map(one => `${one.id} = ${values[one.id] === "" ? "(left empty)" : values[one.id]}`).join("; ")}`,
|
|
513
|
+
needs: needs.length === 0 ? null : `Needs: ${needs.map(needWords).join("; ")}.`,
|
|
514
|
+
};
|
|
515
|
+
const terms = [
|
|
516
|
+
...(about === null ? [] : [about]),
|
|
517
|
+
"Its instructions come from the file, not from you: read what each step is asked before you import.",
|
|
518
|
+
...all,
|
|
519
|
+
...(triggerWords.length === 0 ? [] : [`Triggers:\n${triggerWords.map(one => `• ${one}`).join("\n")}`]),
|
|
520
|
+
...add.map((one, index) => `${parts.scripts[index]}${one.body === undefined ? "" : `\n${one.body}`}`),
|
|
521
|
+
...parts.kept,
|
|
522
|
+
...(parts.filled === null ? [] : [parts.filled]),
|
|
523
|
+
...(parts.needs === null ? [] : [parts.needs]),
|
|
524
|
+
];
|
|
525
|
+
return { name: file.name, repo, definition, triggers, scripts: { add, kept }, values, title: `Import the ${file.name} flow into ${project}`, terms, parts };
|
|
526
|
+
}
|
|
527
|
+
/** Make the planned flow: scripts held, the flow, and its triggers switched off. One transaction. */
|
|
528
|
+
export function importFlow(store, plan, actor, now, dir) {
|
|
529
|
+
return store.transact(() => {
|
|
530
|
+
for (const script of plan.scripts.add) {
|
|
531
|
+
const draft = validateScript(script);
|
|
532
|
+
store.saveFlowScript({ repo: plan.repo, ...draft, digest: scriptDigest(draft), by: actor, held: "imported" }, now);
|
|
533
|
+
}
|
|
534
|
+
const id = store.createFlow({ repo: plan.repo, name: plan.name, definitionJson: JSON.stringify(plan.definition), by: actor }, now);
|
|
535
|
+
for (const trigger of plan.triggers) {
|
|
536
|
+
const made = addFlowTriggerTo(store, store.getFlow(id), trigger, actor, now, dir);
|
|
537
|
+
if (!made.ok)
|
|
538
|
+
throw new FlowFileError(`A ${String(trigger["kind"])} trigger: ${made.message}`);
|
|
539
|
+
store.updateFlowTrigger(made.id, { state: "paused" }, now);
|
|
540
|
+
}
|
|
541
|
+
const held = plan.scripts.add.length, off = plan.triggers.length;
|
|
542
|
+
return { id, said: `Imported ${plan.name}.${off === 0 ? "" : ` Its ${off === 1 ? "trigger is" : `${off} triggers are`} off until you turn ${off === 1 ? "it" : "them"} on.`}${held === 0 ? "" : ` Approve its ${held === 1 ? "script" : `${held} scripts`} on the Scripts panel before ${held === 1 ? "it runs" : "they run"}.`}` };
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/** What a flow's zones and paths are, without where they sit on the canvas: what a round trip keeps. */
|
|
546
|
+
export function flowShape(definition) {
|
|
547
|
+
return createHash("sha256").update(JSON.stringify({ start: definition.start, stages: definition.stages.map(one => ({ ...one, zone: null })) })).digest("hex");
|
|
548
|
+
}
|
|
549
|
+
// ------------------------------------------------------------------ fetching
|
|
550
|
+
const FETCH_HOSTS = new Set(["github.com", "gist.github.com", "raw.githubusercontent.com", "gist.githubusercontent.com"]);
|
|
551
|
+
/** A gist or GitHub file page as the address of its raw text; null when it isn't one Toolroll fetches. */
|
|
552
|
+
export function rawFlowUrl(address) {
|
|
553
|
+
let url;
|
|
554
|
+
try {
|
|
555
|
+
url = new URL(address.trim());
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || !FETCH_HOSTS.has(url.hostname))
|
|
561
|
+
return null;
|
|
562
|
+
if (url.hostname === "github.com") {
|
|
563
|
+
const blob = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
|
|
564
|
+
return blob === null ? null : `https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}`;
|
|
565
|
+
}
|
|
566
|
+
if (url.hostname === "gist.github.com")
|
|
567
|
+
return `https://gist.github.com${url.pathname.replace(/\/+$/, "")}${/\/raw(\/|$)/.test(url.pathname) ? "" : "/raw"}`;
|
|
568
|
+
return url.toString();
|
|
569
|
+
}
|
|
570
|
+
/** A flow file from a gist or GitHub, over HTTPS only, refused past the size cap. Throws FlowFileError in plain words. */
|
|
571
|
+
export async function fetchFlowFile(address, fetcher = fetch) {
|
|
572
|
+
if (!/^https:\/\//i.test(address.trim()))
|
|
573
|
+
refuse("Flow files are fetched over HTTPS only. Use an https:// address, or download the file and import it.");
|
|
574
|
+
const raw = rawFlowUrl(address);
|
|
575
|
+
if (raw === null)
|
|
576
|
+
refuse("Toolroll fetches flow files from a gist or a file on GitHub. Download it and import the file instead.");
|
|
577
|
+
let response;
|
|
578
|
+
try {
|
|
579
|
+
response = await fetcher(raw, { redirect: "follow", signal: AbortSignal.timeout(15_000), headers: { accept: "application/json, text/plain" } });
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return refuse("That address didn't answer. Check it, or download the file and import it.");
|
|
583
|
+
}
|
|
584
|
+
const landed = response.url === undefined || response.url === "" ? null : (() => { try {
|
|
585
|
+
return new URL(response.url);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return null;
|
|
589
|
+
} })();
|
|
590
|
+
if (landed !== null && (landed.protocol !== "https:" || !FETCH_HOSTS.has(landed.hostname)))
|
|
591
|
+
refuse("That address sent Toolroll somewhere other than GitHub. Download the file and import it instead.");
|
|
592
|
+
if (!response.ok)
|
|
593
|
+
refuse(`That address answered ${response.status}. Check it's public, or download the file and import it.`);
|
|
594
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
595
|
+
if (length > FLOW_FILE_MAX_BYTES)
|
|
596
|
+
refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
|
|
597
|
+
if (response.body === null)
|
|
598
|
+
return "";
|
|
599
|
+
const reader = response.body.getReader();
|
|
600
|
+
const chunks = [];
|
|
601
|
+
let size = 0;
|
|
602
|
+
for (;;) {
|
|
603
|
+
const { done, value } = await reader.read();
|
|
604
|
+
if (done)
|
|
605
|
+
break;
|
|
606
|
+
size += value.byteLength;
|
|
607
|
+
if (size > FLOW_FILE_MAX_BYTES) {
|
|
608
|
+
await reader.cancel().catch(() => undefined);
|
|
609
|
+
refuse(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
|
|
610
|
+
}
|
|
611
|
+
chunks.push(value);
|
|
612
|
+
}
|
|
613
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
614
|
+
}
|
package/dist/flow-starters.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type StarterFlow = {
|
|
|
25
25
|
}) => Record<string, unknown>;
|
|
26
26
|
};
|
|
27
27
|
export declare const STARTER_FLOWS: readonly StarterFlow[];
|
|
28
|
+
/** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
|
|
29
|
+
export declare function planeReviewSteps(own: boolean): FlowStepInput[];
|
|
28
30
|
/** Whether a project is Toolroll's own source. */
|
|
29
31
|
export declare function isToolrollRepo(repo: string): boolean;
|
|
30
32
|
export declare const starterOf: (id: string) => StarterFlow | null;
|
package/dist/flow-starters.js
CHANGED
|
@@ -72,7 +72,7 @@ export const STARTER_FLOWS = [
|
|
|
72
72
|
},
|
|
73
73
|
];
|
|
74
74
|
/** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
|
|
75
|
-
function planeReviewSteps(own) {
|
|
75
|
+
export function planeReviewSteps(own) {
|
|
76
76
|
const where = own
|
|
77
77
|
? "This project is Toolroll itself, so the fix belongs here. Find the root cause in this code: cite the files and lines, say why it happened, and say what change fixes it."
|
|
78
78
|
: "This project uses Toolroll; it isn't Toolroll's own code. Find the root cause. If it is in this project's code, cite the files and lines and say what change fixes it. If it is in Toolroll or its settings (a sign-in, a plan limit, a chat app, an integration, the worker), say exactly what to change in Toolroll's settings, and start the report with “No build needed”.";
|
package/dist/flow-steps.js
CHANGED
|
@@ -129,6 +129,13 @@ export async function runFlowSteps(store, repo, now, io) {
|
|
|
129
129
|
store.updateFlowCard(card.id, { waiting }, now);
|
|
130
130
|
continue;
|
|
131
131
|
}
|
|
132
|
+
// A script an imported flow brought waits until a person approves it, like any change to what runs here.
|
|
133
|
+
if (script !== null && script.held !== null) {
|
|
134
|
+
const waiting = `The ${script.name} script came with an imported flow. Approve it on the Scripts panel to run it.`;
|
|
135
|
+
if (card.waiting !== waiting)
|
|
136
|
+
store.updateFlowCard(card.id, { waiting }, now);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
132
139
|
// A script waits for the secrets it names (v90), and runs on its own once they're saved.
|
|
133
140
|
const missing = stage.kind === "check" ? (stage.secrets ?? []).filter(name => readFlowSecrets(io.dir, repo)[name] === undefined) : [];
|
|
134
141
|
if (missing.length > 0) {
|