toolroll 0.9.20 → 0.9.21
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 +14 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +41 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +20 -4
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +4 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +67 -0
- package/dist/scout-report.js +108 -1
- package/dist/scout.d.ts +42 -0
- package/dist/scout.js +225 -16
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
package/dist/flow-gallery-ui.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* its promise, a small drawing of its zones, what it needs and "Use this". A template's page asks only what it
|
|
4
4
|
* needs, previews in plain words what it will do and never do, and creates it — the same answers it previewed.
|
|
5
5
|
*/
|
|
6
|
-
import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, OUTDATED_COMMANDS } from "./flow-gallery.js";
|
|
6
|
+
import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, OUTDATED_COMMANDS, SEND_RESULT } from "./flow-gallery.js";
|
|
7
|
+
import { choiceTargets } from "./flows.js";
|
|
7
8
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
9
|
export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
|
|
9
10
|
`.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}` +
|
|
@@ -16,7 +17,7 @@ export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{ma
|
|
|
16
17
|
`.gallery .gallery-card a.button-link{align-self:flex-start;min-height:36px;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
|
|
17
18
|
`.gallery-blank{margin-top:20px}` +
|
|
18
19
|
`.gallery-use{max-width:720px;min-width:0}.gallery-use .gallery-zones{height:88px;margin:4px 0 12px}.gallery-use form{display:grid;gap:12px;margin:0}.gallery-use label{display:grid;gap:4px;font-weight:500}` +
|
|
19
|
-
`.gallery-use label small{font-weight:400;color:var(--so-muted)}` +
|
|
20
|
+
`.gallery-use label small{font-weight:400;color:var(--so-muted)}.gallery-use label.gallery-check{display:flex;align-items:flex-start;gap:8px;font-weight:500}.gallery-use label.gallery-check input{margin-top:3px;width:16px;height:16px;min-height:0;flex:none}.gallery-use label.gallery-check small{display:block}` +
|
|
20
21
|
`.gallery-use input:not([type=hidden]),.gallery-use select{box-sizing:border-box;width:100%;max-width:100%;min-height:32px;padding:0 10px;border:1px solid var(--so-input-line);border-radius:8px;background:var(--so-paper);color:var(--so-ink);font:inherit}` +
|
|
21
22
|
`.gallery-preview{display:grid;gap:8px;padding:16px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);min-width:0}.gallery-preview h2{margin:0;font-size:.9375rem}` +
|
|
22
23
|
`.gallery-preview ul{margin:0;padding-left:18px;line-height:1.55}.gallery-preview p{margin:0;overflow-wrap:anywhere}.gallery-preview .gallery-never{font-weight:600}` +
|
|
@@ -34,11 +35,11 @@ export function zonesDiagram(definition, label) {
|
|
|
34
35
|
const pad = 40;
|
|
35
36
|
const box = `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
|
|
36
37
|
const centre = (id) => { const at = definition.stages.find(one => one.id === id)?.zone; return at === undefined ? null : { x: at.x + at.w / 2, y: at.y + at.h / 2 }; };
|
|
37
|
-
const lines = definition.stages.flatMap(stage => [stage.next, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? [])].map(to => {
|
|
38
|
+
const lines = definition.stages.flatMap(stage => [stage.next, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage)].map(to => {
|
|
38
39
|
const from = centre(stage.id), end = centre(to);
|
|
39
40
|
return from === null || end === null ? "" : `<line x1="${from.x}" y1="${from.y}" x2="${end.x}" y2="${end.y}" vector-effect="non-scaling-stroke"/>`;
|
|
40
41
|
})).join("");
|
|
41
|
-
const rects = definition.stages.map(one => `<rect x="${one.zone.x}" y="${one.zone.y}" width="${one.zone.w}" height="${one.zone.h}" rx="36" data-person="${one.kind === "approval"}" vector-effect="non-scaling-stroke"/>`).join("");
|
|
42
|
+
const rects = definition.stages.map(one => `<rect x="${one.zone.x}" y="${one.zone.y}" width="${one.zone.w}" height="${one.zone.h}" rx="36" data-person="${one.kind === "approval" || one.kind === "choose"}" vector-effect="non-scaling-stroke"/>`).join("");
|
|
42
43
|
return `<svg class="gallery-zones" viewBox="${box}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${e(label)}">${lines}${rects}</svg>`;
|
|
43
44
|
}
|
|
44
45
|
const needsList = (template) => template.needs.length === 0 ? `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
|
|
@@ -73,13 +74,13 @@ export function galleryUseHtml(input) {
|
|
|
73
74
|
`<p class="gallery-never">${e(input.preview.built.never)}</p>` +
|
|
74
75
|
(input.preview.startsFrom.length === 0 ? `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => `<p class="meta">Starts from: ${e(one)}</p>`).join("")) +
|
|
75
76
|
`<details><summary>Every step</summary><ol>${input.preview.steps.map(one => `<li>${e(one.replace(/^\d+\.\s/, ""))}</li>`).join("")}</ol></details></section>`;
|
|
76
|
-
const asks = template.asks.length > 0 || input.projects.length > 1;
|
|
77
77
|
return `<section class="gallery-use" data-gallery-template="${e(template.id)}">` +
|
|
78
78
|
(input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`) +
|
|
79
79
|
`<p>${e(template.promise)}</p>${input.diagram === null ? "" : zonesDiagram(input.diagram, `Zones: ${input.diagram.stages.map(one => one.title).join(", ")}`)}${needsList(template)}` +
|
|
80
80
|
`<form method="post" action="/flows/new/${e(template.id)}" data-gallery-use><input type="hidden" name="csrf" value="${e(input.csrf)}">` +
|
|
81
81
|
`<input type="hidden" name="previewed" value="${e(input.preview?.digest ?? "")}">${project}${template.asks.map(field).join("")}` +
|
|
82
|
-
`<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label
|
|
83
|
-
`<
|
|
82
|
+
`<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label>` +
|
|
83
|
+
`<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" ? " checked" : ""} data-send-result><span>${e(SEND_RESULT.title)}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>${preview}` +
|
|
84
|
+
`<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create">Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>` +
|
|
84
85
|
`</form><p class="meta"><a href="/flows/new">All templates</a></p></section>`;
|
|
85
86
|
}
|
package/dist/flow-gallery.d.ts
CHANGED
|
@@ -62,6 +62,14 @@ export declare const galleryTemplateOf: (id: string) => GalleryTemplate | null;
|
|
|
62
62
|
export type GalleryAnswers = Record<string, string>;
|
|
63
63
|
/** Each question's default for a project: the branch pull requests go to, when that's set up. */
|
|
64
64
|
export declare function galleryDefaults(store: Store, template: GalleryTemplate, repo: string | null): GalleryAnswers;
|
|
65
|
+
/** The optional last step every template offers, and the answer that turns it on. */
|
|
66
|
+
export declare const SEND_RESULT: {
|
|
67
|
+
readonly key: "send-result";
|
|
68
|
+
readonly title: "Send me the result";
|
|
69
|
+
readonly does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps.";
|
|
70
|
+
};
|
|
71
|
+
/** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
|
|
72
|
+
export declare function withSendResult(definition: FlowDefinition): FlowDefinition;
|
|
65
73
|
/** The answers, checked in plain words, with what they fill in. Throws. */
|
|
66
74
|
export declare function checkAnswers(template: GalleryTemplate, given: GalleryAnswers): GalleryAnswers;
|
|
67
75
|
export type GalleryBuilt = {
|
package/dist/flow-gallery.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* where an answer goes and `{{script.<name>}}` where its script's name goes — so it can be written out as a
|
|
9
9
|
* shared flow file later. Every one is built only from existing zone and trigger kinds, and none merges:
|
|
10
10
|
* a pull request it opens waits for a person.
|
|
11
|
+
*
|
|
12
|
+
* Every template can end with "Send me the result" (a "Send to me" zone before its end), off unless asked for
|
|
13
|
+
* (`send-result: yes`): what the flow did reaches the person in their chat apps.
|
|
11
14
|
*/
|
|
12
15
|
import { createHash } from "node:crypto";
|
|
13
16
|
import { scanForSecrets } from "./evidence.js";
|
|
@@ -15,7 +18,7 @@ import { flowDefinitionOf } from "./flow-engine.js";
|
|
|
15
18
|
import { saveScript, validateScript } from "./flow-scripts.js";
|
|
16
19
|
import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
|
|
17
20
|
import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
|
|
18
|
-
import { clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
|
|
21
|
+
import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
|
|
19
22
|
import { publishingOf } from "./pull-request-flow.js";
|
|
20
23
|
export const GALLERY_GROUPS = [
|
|
21
24
|
{ id: "ship", label: "Ship code" },
|
|
@@ -291,9 +294,38 @@ const oneLine = (value, cap, what) => {
|
|
|
291
294
|
throw new Error(`That ${what} looks like it holds a key or password. Keep secrets out of it.`);
|
|
292
295
|
return said;
|
|
293
296
|
};
|
|
297
|
+
/** The optional last step every template offers, and the answer that turns it on. */
|
|
298
|
+
export const SEND_RESULT = { key: "send-result", title: "Send me the result", does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps." };
|
|
299
|
+
/** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
|
|
300
|
+
export function withSendResult(definition) {
|
|
301
|
+
// Its main end: Done, else an end that isn't a filtered-out one (drawn in rose), else any end.
|
|
302
|
+
const ends = definition.stages.filter(one => one.kind === "done");
|
|
303
|
+
const end = ends.find(one => one.id === "done") ?? ends.find(one => one.zone.color !== "rose") ?? ends[0];
|
|
304
|
+
if (end === undefined || definition.stages.some(one => one.kind === "send" && one.next === end.id))
|
|
305
|
+
return definition;
|
|
306
|
+
let id = "send-result";
|
|
307
|
+
for (let n = 2; definition.stages.some(one => one.id === id); n++)
|
|
308
|
+
id = `send-result-${n}`;
|
|
309
|
+
const into = (to) => to === end.id ? id : to;
|
|
310
|
+
const stages = definition.stages.map(one => ({
|
|
311
|
+
...one, next: one.next === null ? null : into(one.next), onFail: one.onFail === null ? null : into(one.onFail),
|
|
312
|
+
...(one.limit === undefined ? {} : { limit: { ...one.limit, to: one.limit.to === null ? null : into(one.limit.to) } }),
|
|
313
|
+
sort: one.sort === null ? null : { ...one.sort, answers: one.sort.answers.map(answer => ({ ...answer, to: into(answer.to) })) },
|
|
314
|
+
...(one.routes === undefined ? {} : { routes: one.routes.map(route => ({ ...route, to: into(route.to) })) }),
|
|
315
|
+
...(one.options === undefined || choiceTargets(one).length === 0 ? {} : { options: one.options.map(option => ({ ...option, to: into(option.to) })) }),
|
|
316
|
+
}));
|
|
317
|
+
// Where the end was, and the end moves along to make room.
|
|
318
|
+
const send = { id, title: SEND_RESULT.title, kind: "send", zone: { ...end.zone, color: "green", h: 220 }, instructions: null, planning: null, approver: null,
|
|
319
|
+
message: null, close: null, script: null, sort: null, next: end.id, onFail: null };
|
|
320
|
+
const moved = stages.map(one => one.id === end.id ? { ...one, zone: { ...one.zone, x: one.zone.x + Math.max(300, one.zone.w + 40) } } : one);
|
|
321
|
+
return { ...definition, ...(definition.start === end.id ? { start: id } : {}), stages: [...moved.filter(one => one.id !== end.id), send, moved.find(one => one.id === end.id)] };
|
|
322
|
+
}
|
|
294
323
|
/** The answers, checked in plain words, with what they fill in. Throws. */
|
|
295
324
|
export function checkAnswers(template, given) {
|
|
296
325
|
const answers = {};
|
|
326
|
+
// Off unless asked for; only said when on, so a template's answers read as before.
|
|
327
|
+
if (given[SEND_RESULT.key] === "yes")
|
|
328
|
+
answers[SEND_RESULT.key] = "yes";
|
|
297
329
|
for (const ask of template.asks) {
|
|
298
330
|
const value = String(given[ask.key] ?? ask.default).trim();
|
|
299
331
|
if (ask.key === "label")
|
|
@@ -386,10 +418,12 @@ export function buildFromGallery(store, template, repo, given) {
|
|
|
386
418
|
return { name, about: draft.about, body: draft.body, timeoutMinutes: draft.timeoutMinutes, existing };
|
|
387
419
|
});
|
|
388
420
|
const github = githubRepoOf(repo);
|
|
389
|
-
const
|
|
421
|
+
const drawn = template.definition !== undefined ? structuredClone(template.definition)
|
|
390
422
|
: flowFromSteps(fill(template.ownSteps !== undefined && isToolrollRepo(repo) ? template.ownSteps : template.steps, answers, names, github), null);
|
|
423
|
+
const sends = answers[SEND_RESULT.key] === "yes";
|
|
424
|
+
const definition = sends ? withSendResult(drawn) : drawn;
|
|
391
425
|
return { template, repo, answers, definition, triggers: fill(template.triggers, answers, names, github), scripts,
|
|
392
|
-
does: fill(template.does, answers, names, github), never: template.never };
|
|
426
|
+
does: [...fill(template.does, answers, names, github), ...(sends && definition !== drawn ? [SEND_RESULT.does] : [])], never: template.never };
|
|
393
427
|
}
|
|
394
428
|
/** A template's zones as its defaults draw them, for the gallery's small drawing; no project needed. */
|
|
395
429
|
export function galleryDiagram(template) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type FlowDecision } from "./flow-engine.js";
|
|
2
|
+
import { type FlowDefinition, type FlowStage } from "./flows.js";
|
|
3
|
+
import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
|
|
4
|
+
/** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
|
|
5
|
+
export declare const FLOW_SEND_KEY: RegExp;
|
|
6
|
+
/** The notice a "Person chooses" visit sends: `flow-choose:<card>:<entry>`, a "flow-decision" like an approval's. */
|
|
7
|
+
export declare const FLOW_CHOOSE_KEY: RegExp;
|
|
8
|
+
/** What the person is asked under the buttons. */
|
|
9
|
+
export declare const REPLY_ASK = "Or reply with what you'd change.";
|
|
10
|
+
export type FlowSendLink = {
|
|
11
|
+
label: string;
|
|
12
|
+
path: string;
|
|
13
|
+
} | {
|
|
14
|
+
label: string;
|
|
15
|
+
url: string;
|
|
16
|
+
};
|
|
17
|
+
export type FlowSendContent = {
|
|
18
|
+
/** The card's title and the step it comes from. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** The step it comes from, when known. */
|
|
21
|
+
from?: string;
|
|
22
|
+
summary: string;
|
|
23
|
+
links: FlowSendLink[];
|
|
24
|
+
/** The build's result whose screenshots go with it, when it saved any. */
|
|
25
|
+
shots: {
|
|
26
|
+
taskId: string;
|
|
27
|
+
run: number;
|
|
28
|
+
} | null;
|
|
29
|
+
/** choose: the options as they were offered, and whether a reply is taken. */
|
|
30
|
+
options?: {
|
|
31
|
+
choice: number;
|
|
32
|
+
label: string;
|
|
33
|
+
}[];
|
|
34
|
+
reply?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
|
|
37
|
+
export declare function flowPersonOf(card: Pick<FlowCardRow, "owner">, flow: Pick<FlowRow, "owner">): string;
|
|
38
|
+
/** What a visit sends its person, worked out from the card as it is now. */
|
|
39
|
+
export declare function flowSendContent(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): FlowSendContent;
|
|
40
|
+
/** A kept content, read back; null when it can't be. */
|
|
41
|
+
export declare function readFlowSend(json: string): FlowSendContent | null;
|
|
42
|
+
/** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
|
|
43
|
+
export declare function flowSendText(content: FlowSendContent): string;
|
|
44
|
+
/** The links a sent visit's message carries as buttons: Toolroll's own pages, the first one first. */
|
|
45
|
+
export declare function flowSendPaths(content: FlowSendContent): {
|
|
46
|
+
label: string;
|
|
47
|
+
path: string;
|
|
48
|
+
}[];
|
|
49
|
+
/** A "Send to me" visit: what the step before produced, to the person once; the card moves on after. */
|
|
50
|
+
export declare function sendStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
|
|
51
|
+
/** A "Person chooses" visit: the same content with the zone's options, to the person once; the card waits for their choice. */
|
|
52
|
+
export declare function chooseStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): void;
|
|
53
|
+
/** The words above a choice's buttons: what was done, then what to do. */
|
|
54
|
+
export declare function flowChooseBody(content: FlowSendContent): string;
|
|
55
|
+
export type FlowChoiceVisit = {
|
|
56
|
+
card: FlowCardRow;
|
|
57
|
+
flow: FlowRow;
|
|
58
|
+
stage: FlowStage;
|
|
59
|
+
definition: FlowDefinition;
|
|
60
|
+
person: string;
|
|
61
|
+
};
|
|
62
|
+
/** The card, if it is still at that visit, waiting for its person's choice. */
|
|
63
|
+
export declare function flowChoiceAt(store: Store, cardId: number, entry: number): FlowChoiceVisit | null;
|
|
64
|
+
/**
|
|
65
|
+
* A person's choice on a card waiting at a "Person chooses" zone: an option (by its place, and the words and zone they saw), or a
|
|
66
|
+
* reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
|
|
67
|
+
* history and the ledger: "Telegram", "Slack", "the console".
|
|
68
|
+
*/
|
|
69
|
+
export declare function chooseFlowCard(store: Store, input: {
|
|
70
|
+
card: number;
|
|
71
|
+
entry?: number;
|
|
72
|
+
choice: number | null;
|
|
73
|
+
label?: string;
|
|
74
|
+
to?: string;
|
|
75
|
+
note: string | null;
|
|
76
|
+
actor: string;
|
|
77
|
+
where: string;
|
|
78
|
+
repos: readonly string[];
|
|
79
|
+
evidenceRoot?: string;
|
|
80
|
+
}, now: Date): FlowDecision;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Send to me" and "Person chooses" zones (flows.ts): what a card's step before produced, sent to the card's person.
|
|
3
|
+
*
|
|
4
|
+
* - The person is the card's owner, else the flow's.
|
|
5
|
+
* - What is sent: the card's title and the step it comes from, a summary (the build's own report, a research report,
|
|
6
|
+
* or what the step before wrote), its links (the result, its pull request, the report, the card) and the build's
|
|
7
|
+
* screenshots. It is worked out once per visit and kept (flow_send), so every chat app and the card show the same.
|
|
8
|
+
* - It goes as one notification to that person, which each chat app they paired delivers (Telegram: the screenshots as
|
|
9
|
+
* one album; Slack and Discord: uploads in its thread; Teams: a link to them), and the card shows it in the console.
|
|
10
|
+
* - A choose visit carries the zone's options as buttons (telegram-flow.ts, chat-flow.ts, and the card). Each leads to
|
|
11
|
+
* a zone, or ends the card as Ignored; a reply instead is the {{note}} for where replies go. Every choice is made
|
|
12
|
+
* here (chooseFlowCard), for exactly the visit the person saw, by that person alone, and ledgered with where.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic and model-free, in the worker's flow pass.
|
|
15
|
+
*/
|
|
16
|
+
import { assignmentOf } from "./assignment.js";
|
|
17
|
+
import { withActor } from "./actor.js";
|
|
18
|
+
import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
|
|
19
|
+
import { notifyPeople } from "./flow-people.js";
|
|
20
|
+
import { FLOW_END, replyTarget } from "./flows.js";
|
|
21
|
+
import { FLOW_SHOTS_KIND } from "./store.js";
|
|
22
|
+
/** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
|
|
23
|
+
export const FLOW_SEND_KEY = /^flow-send:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
|
|
24
|
+
/** The notice a "Person chooses" visit sends: `flow-choose:<card>:<entry>`, a "flow-decision" like an approval's. */
|
|
25
|
+
export const FLOW_CHOOSE_KEY = /^flow-choose:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
|
|
26
|
+
/** What the person is asked under the buttons. */
|
|
27
|
+
export const REPLY_ASK = "Or reply with what you'd change.";
|
|
28
|
+
const SUMMARY_CHARS = 1500;
|
|
29
|
+
const STEP_CHARS = 500;
|
|
30
|
+
/** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
|
|
31
|
+
export function flowPersonOf(card, flow) {
|
|
32
|
+
return card.owner ?? flow.owner;
|
|
33
|
+
}
|
|
34
|
+
const cut = (text, cap) => text.length <= cap ? text : `${text.slice(0, cap - 1).trimEnd()}…`;
|
|
35
|
+
const plain = (text) => text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
36
|
+
/** The zone the card came from into this visit: its latest move, else a zone that leads here. */
|
|
37
|
+
function stepBefore(store, definition, card) {
|
|
38
|
+
const events = store.flowEvents(card.id);
|
|
39
|
+
const last = events[events.length - 1];
|
|
40
|
+
const from = last !== undefined && last.toStage === card.stage && last.fromStage !== card.stage ? last.fromStage : null;
|
|
41
|
+
return definition.stages.find(one => one.id === from)
|
|
42
|
+
?? definition.stages.find(one => one.next === card.stage || one.onFail === card.stage || (one.options ?? []).some(option => option.to === card.stage)) ?? null;
|
|
43
|
+
}
|
|
44
|
+
/** A result's facts for the person, read as the operator would: its report, its pull request and screenshots. */
|
|
45
|
+
function resultOf(store, taskId, now, evidenceRoot) {
|
|
46
|
+
const repo = store.lookupRef(taskId)?.repo ?? null;
|
|
47
|
+
if (repo === null)
|
|
48
|
+
return null;
|
|
49
|
+
const snapshot = assignmentOf(store, taskId, now, { principal: "operator", repos: [repo] }, evidenceRoot);
|
|
50
|
+
const receipt = snapshot?.receipt ?? null;
|
|
51
|
+
const url = snapshot?.publication?.prUrl ?? null;
|
|
52
|
+
return {
|
|
53
|
+
taskId: receipt?.taskId ?? snapshot?.activeTaskId ?? taskId, report: receipt?.agentReport ?? null,
|
|
54
|
+
pullRequest: url !== null && /^https:\/\/[^\s<>"]+$/.test(url) ? url : null,
|
|
55
|
+
shots: receipt !== null && store.artifactsFor(receipt.runId).some(one => one.kind === "screenshot") ? { taskId: receipt.taskId, run: receipt.runId } : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** What a visit sends its person, worked out from the card as it is now. */
|
|
59
|
+
export function flowSendContent(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
60
|
+
const before = stepBefore(store, definition, card);
|
|
61
|
+
const output = before === null ? undefined : card.outputs[before.id]?.trim() || undefined;
|
|
62
|
+
// The task the step before produced (a build or research zone hands it over), and the card's main build.
|
|
63
|
+
const handed = card.task !== null && before !== null && (before.kind === "task" || before.kind === "report") ? card.task : null;
|
|
64
|
+
const report = handed !== null && before?.kind === "report" ? handed : null;
|
|
65
|
+
const build = before?.kind === "task" && handed !== null ? handed : card.primaryTask;
|
|
66
|
+
const result = build === null ? null : resultOf(store, build, now, evidenceRoot);
|
|
67
|
+
const lines = [];
|
|
68
|
+
if (before?.kind === "report" && output !== undefined)
|
|
69
|
+
lines.push(cut(output, SUMMARY_CHARS));
|
|
70
|
+
else if (result?.report != null && result.report.trim() !== "") {
|
|
71
|
+
lines.push(cut(result.report.trim(), SUMMARY_CHARS));
|
|
72
|
+
if (output !== undefined && before?.kind !== "task")
|
|
73
|
+
lines.push(`${before.title}: ${cut(output, STEP_CHARS)}`);
|
|
74
|
+
}
|
|
75
|
+
else if (output !== undefined)
|
|
76
|
+
lines.push(cut(output, SUMMARY_CHARS));
|
|
77
|
+
else {
|
|
78
|
+
// Nothing from the step before: the latest thing any zone wrote, else the card's own details.
|
|
79
|
+
const latest = [...definition.stages].reverse().find(one => one.id !== stage.id && card.outputs[one.id]?.trim());
|
|
80
|
+
lines.push(latest !== undefined ? `${latest.title}: ${cut(card.outputs[latest.id].trim(), SUMMARY_CHARS)}` : card.description === null ? "Nothing was written down for this step." : cut(card.description, SUMMARY_CHARS));
|
|
81
|
+
}
|
|
82
|
+
const links = [];
|
|
83
|
+
if (result !== null)
|
|
84
|
+
links.push({ label: "Result", path: `/t/${encodeURIComponent(result.taskId)}` });
|
|
85
|
+
if (result?.pullRequest != null)
|
|
86
|
+
links.push({ label: "Pull request", url: result.pullRequest });
|
|
87
|
+
if (report !== null)
|
|
88
|
+
links.push({ label: "Report", path: `/t/${encodeURIComponent(report)}` });
|
|
89
|
+
links.push({ label: "Card", path: flowCardHref(flow.id, card.id) });
|
|
90
|
+
const options = stage.kind === "choose" ? (stage.options ?? []).map((one, choice) => ({ choice, label: one.label })) : undefined;
|
|
91
|
+
return {
|
|
92
|
+
title: cut(plain(before === null ? card.title : `${card.title} · ${before.title}`), 200), ...(before === null ? {} : { from: before.title }),
|
|
93
|
+
summary: plain(lines.join("\n\n")), links, shots: result?.shots ?? null,
|
|
94
|
+
...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** A kept content, read back; null when it can't be. */
|
|
98
|
+
export function readFlowSend(json) {
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(json);
|
|
101
|
+
return typeof raw.title === "string" && typeof raw.summary === "string" && Array.isArray(raw.links) ? raw : null;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
|
|
108
|
+
export function flowSendText(content) {
|
|
109
|
+
const pull = content.links.find((one) => "url" in one);
|
|
110
|
+
return [content.title, content.summary, ...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`])].filter(one => one !== "").join("\n\n");
|
|
111
|
+
}
|
|
112
|
+
/** The links a sent visit's message carries as buttons: Toolroll's own pages, the first one first. */
|
|
113
|
+
export function flowSendPaths(content) {
|
|
114
|
+
return content.links.filter((one) => "path" in one);
|
|
115
|
+
}
|
|
116
|
+
/** A visit's content, kept the first time; later passes (and every chat app) read the same one. */
|
|
117
|
+
function keep(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
118
|
+
const person = flowPersonOf(card, flow);
|
|
119
|
+
const kept = store.flowSend(card.id, card.entry);
|
|
120
|
+
const read = kept === null ? null : readFlowSend(kept.contentJson);
|
|
121
|
+
if (kept !== null && read !== null)
|
|
122
|
+
return { content: read, person: kept.person, fresh: false };
|
|
123
|
+
const content = flowSendContent(store, flow, definition, stage, card, now, evidenceRoot);
|
|
124
|
+
const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: JSON.stringify(content) }, now);
|
|
125
|
+
return { content, person, fresh };
|
|
126
|
+
}
|
|
127
|
+
/** One notice to the person, and its screenshots after it. Never as anyone's own act: they asked for it in the flow. */
|
|
128
|
+
function enqueue(store, flow, card, person, content, notice, now) {
|
|
129
|
+
if (!store.accountCanAccess(person, flow.repo))
|
|
130
|
+
return;
|
|
131
|
+
withActor(null, () => {
|
|
132
|
+
store.enqueueNotification({ dedupeKey: notice.key, kind: notice.kind, recipient: person, ...(notice.attention ? { pushClass: "attention" } : {}),
|
|
133
|
+
subject: notice.subject.slice(0, 200), body: notice.body.slice(0, 4000), link: flowSendPaths(content)[0]?.path ?? flowCardHref(flow.id, card.id), source: { project: flow.repo } }, now);
|
|
134
|
+
if (content.shots !== null && store.lookupRef(content.shots.taskId)?.repo != null && store.accountCanAccess(person, store.lookupRef(content.shots.taskId).repo)) {
|
|
135
|
+
store.enqueueNotification({ dedupeKey: `flow-shots:${card.id}:${card.entry}`, kind: FLOW_SHOTS_KIND, recipient: person, subject: "Screenshots with this result", body: "",
|
|
136
|
+
link: `/t/${encodeURIComponent(content.shots.taskId)}`, source: { run: content.shots.run } }, now);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** A "Send to me" visit: what the step before produced, to the person once; the card moves on after. */
|
|
141
|
+
export function sendStep(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
142
|
+
const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
|
|
143
|
+
if (!fresh)
|
|
144
|
+
return;
|
|
145
|
+
store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: flowSendText(content) } }, now);
|
|
146
|
+
enqueue(store, flow, card, person, content, { key: `flow-send:${card.id}:${card.entry}`, kind: "flow-card", attention: false,
|
|
147
|
+
subject: `${flow.name}: ${content.title}`, body: [content.summary, ...content.links.filter((one) => "url" in one).map(one => `${one.label}: ${one.url}`)].join("\n\n") }, now);
|
|
148
|
+
}
|
|
149
|
+
/** A "Person chooses" visit: the same content with the zone's options, to the person once; the card waits for their choice. */
|
|
150
|
+
export function chooseStep(store, flow, definition, stage, card, now, evidenceRoot) {
|
|
151
|
+
const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
|
|
152
|
+
const waiting = `Waiting for ${person} to choose`;
|
|
153
|
+
if (card.waiting !== waiting)
|
|
154
|
+
store.updateFlowCard(card.id, { waiting }, now);
|
|
155
|
+
if (!fresh)
|
|
156
|
+
return;
|
|
157
|
+
enqueue(store, flow, card, person, content, { key: `flow-choose:${card.id}:${card.entry}`, kind: "flow-decision", attention: true,
|
|
158
|
+
subject: `${flow.name}: ${content.title}`, body: flowChooseBody(content) }, now);
|
|
159
|
+
}
|
|
160
|
+
/** The words above a choice's buttons: what was done, then what to do. */
|
|
161
|
+
export function flowChooseBody(content) {
|
|
162
|
+
const pull = content.links.find((one) => "url" in one);
|
|
163
|
+
return [content.summary, ...(pull === undefined ? [] : [`${pull.label}: ${pull.url}`]), content.reply === true ? `Choose one. ${REPLY_ASK}` : "Choose one."].join("\n\n");
|
|
164
|
+
}
|
|
165
|
+
/** The card, if it is still at that visit, waiting for its person's choice. */
|
|
166
|
+
export function flowChoiceAt(store, cardId, entry) {
|
|
167
|
+
const card = store.getFlowCard(cardId);
|
|
168
|
+
const flow = card === null ? null : store.getFlow(card.flow);
|
|
169
|
+
const definition = flow === null ? null : flowDefinitionOf(flow);
|
|
170
|
+
if (card === null || flow === null || definition === null || card.state !== "active" || card.entry !== entry)
|
|
171
|
+
return null;
|
|
172
|
+
const stage = definition.stages.find(one => one.id === card.stage);
|
|
173
|
+
if (stage === undefined || stage.kind !== "choose")
|
|
174
|
+
return null;
|
|
175
|
+
return { card, flow, stage, definition, person: flowPersonOf(card, flow) };
|
|
176
|
+
}
|
|
177
|
+
const via = (where) => where === "" ? "" : ` in ${where}`;
|
|
178
|
+
/**
|
|
179
|
+
* A person's choice on a card waiting at a "Person chooses" zone: an option (by its place, and the words and zone they saw), or a
|
|
180
|
+
* reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
|
|
181
|
+
* history and the ledger: "Telegram", "Slack", "the console".
|
|
182
|
+
*/
|
|
183
|
+
export function chooseFlowCard(store, input, now) {
|
|
184
|
+
const card = store.getFlowCard(input.card);
|
|
185
|
+
const flow = card === null ? null : store.getFlow(card.flow);
|
|
186
|
+
if (card === null || flow === null || card.state !== "active" || !input.repos.includes(flow.repo))
|
|
187
|
+
return { ok: false, message: "That card is no longer waiting." };
|
|
188
|
+
const visit = flowChoiceAt(store, card.id, input.entry ?? card.entry);
|
|
189
|
+
if (visit === null)
|
|
190
|
+
return { ok: false, message: "That card has moved on since; nothing was changed." };
|
|
191
|
+
const { stage, definition, person } = visit;
|
|
192
|
+
if (person !== input.actor)
|
|
193
|
+
return { ok: false, message: `Only ${person} chooses here.` };
|
|
194
|
+
const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? id;
|
|
195
|
+
const ledger = (outcome, said) => store.recordAction({ at: now.toISOString(), actor: input.actor, repo: flow.repo, taskId: card.primaryTask, runId: null,
|
|
196
|
+
action: "flow choice", outcome, source: "request", detail: `${flow.name} · card ${card.id} · ${stage.title}: ${said} · via ${input.where || "Toolroll"}` });
|
|
197
|
+
if (input.choice !== null) {
|
|
198
|
+
const option = (stage.options ?? [])[input.choice];
|
|
199
|
+
if (option === undefined || (input.label !== undefined && option.label !== input.label) || (input.to !== undefined && option.to !== input.to))
|
|
200
|
+
return { ok: false, message: "Those options changed since; nothing was changed." };
|
|
201
|
+
if (option.to === FLOW_END) {
|
|
202
|
+
const closed = store.transact(() => {
|
|
203
|
+
if (!store.moveFlowCard(card.id, { to: card.stage, outcome: "cancelled", actor: input.actor, historyNote: `Ignored: chose “${option.label}”${via(input.where)}`, expectEntry: card.entry }, now))
|
|
204
|
+
return false;
|
|
205
|
+
store.updateFlowCard(card.id, { state: "cancelled", waiting: "Ignored" }, now);
|
|
206
|
+
store.retireFlowChoices(card.id, card.entry, now);
|
|
207
|
+
ledger("ignored", `“${option.label}”`);
|
|
208
|
+
return true;
|
|
209
|
+
});
|
|
210
|
+
return closed ? { ok: true, said: "Ignored. The card is closed." } : { ok: false, message: "That card has moved on since; nothing was changed." };
|
|
211
|
+
}
|
|
212
|
+
const moved = store.transact(() => {
|
|
213
|
+
if (!store.moveFlowCard(card.id, { to: option.to, outcome: "moved", actor: input.actor, historyNote: `Chose “${option.label}”${via(input.where)}`, expectEntry: card.entry }, now))
|
|
214
|
+
return false;
|
|
215
|
+
store.retireFlowChoices(card.id, card.entry, now);
|
|
216
|
+
ledger("chosen", `“${option.label}”`);
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
if (!moved)
|
|
220
|
+
return { ok: false, message: "That card has moved on since; nothing was changed." };
|
|
221
|
+
if (card.owner !== null && card.owner !== input.actor)
|
|
222
|
+
notifyPeople(store, card, [card.owner], input.actor, { key: `chose:${card.entry}`, subject: `${input.actor} chose “${option.label}” for “${card.title}”`, body: `It moves to ${titleOf(option.to)}.` }, now);
|
|
223
|
+
const title = titleOf(option.to);
|
|
224
|
+
return { ok: true, said: `${option.label}. Moved to ${title}${/[.?!]$/.test(title) ? "" : "."}` };
|
|
225
|
+
}
|
|
226
|
+
const note = (input.note ?? "").trim();
|
|
227
|
+
const target = replyTarget(stage);
|
|
228
|
+
if (target === null)
|
|
229
|
+
return { ok: false, message: "Choose one of the options; this step doesn't take a reply." };
|
|
230
|
+
if (input.to !== undefined && target !== input.to)
|
|
231
|
+
return { ok: false, message: "Where replies go changed since; nothing was changed." };
|
|
232
|
+
if (note === "")
|
|
233
|
+
return { ok: false, message: "Say what you'd change." };
|
|
234
|
+
if (note.length > 2000)
|
|
235
|
+
return { ok: false, message: "Keep it under 2,000 characters." };
|
|
236
|
+
const revision = revisionWithNote(store, card, definition.stages.find(one => one.id === target), note, input.actor, input.repos, input.evidenceRoot, now);
|
|
237
|
+
const moved = store.transact(() => {
|
|
238
|
+
if (!store.moveFlowCard(card.id, { to: target, outcome: "sent-back", actor: input.actor, note, ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
|
|
239
|
+
return false;
|
|
240
|
+
store.retireFlowChoices(card.id, card.entry, now);
|
|
241
|
+
ledger("replied", `a reply to ${titleOf(target)}`);
|
|
242
|
+
return true;
|
|
243
|
+
});
|
|
244
|
+
if (!moved)
|
|
245
|
+
return { ok: false, message: "That card has moved on since; nothing was changed." };
|
|
246
|
+
if (card.owner !== null && card.owner !== input.actor)
|
|
247
|
+
notifyPeople(store, card, [card.owner], input.actor, { key: `replied:${card.entry}`, subject: `${input.actor} replied on “${card.title}”`, body: `Sent to ${titleOf(target)}:\n\n${note}`, attention: true }, now);
|
|
248
|
+
return { ok: true, said: revision === null ? `Sent to ${titleOf(target)} with your note.` : `Sent to ${titleOf(target)}: a revision was made with your note.` };
|
|
249
|
+
}
|
package/dist/flow-share.js
CHANGED
|
@@ -81,12 +81,15 @@ function stepOf(stage, ask) {
|
|
|
81
81
|
step["teammate"] = stage.teammate;
|
|
82
82
|
if (stage.reply === true)
|
|
83
83
|
step["reply"] = true;
|
|
84
|
+
// A choice's buttons; "end" ignores the card. A build in another project names a path on this computer, so it stays here.
|
|
85
|
+
if (stage.options !== undefined)
|
|
86
|
+
step["options"] = stage.options.map(one => ({ label: one.label, goesTo: one.to }));
|
|
84
87
|
if (stage.limit !== undefined)
|
|
85
88
|
Object.assign(step, { remindAfter: words(stage.limit.minutes), ...(stage.limit.to === null ? {} : { thenMoveTo: stage.limit.to }) });
|
|
86
89
|
if (stage.next !== null)
|
|
87
90
|
step["next"] = stage.next;
|
|
88
91
|
if (stage.onFail !== null)
|
|
89
|
-
step[stage.kind === "sort" ? "ifNotSure" : stage.kind === "wait" ? "ifNoReply" : "ifFails"] = stage.onFail;
|
|
92
|
+
step[stage.kind === "sort" ? "ifNotSure" : stage.kind === "wait" ? "ifNoReply" : stage.kind === "choose" ? "ifReplied" : "ifFails"] = stage.onFail;
|
|
90
93
|
step["at"] = { x: stage.zone.x, y: stage.zone.y, w: stage.zone.w, h: stage.zone.h, color: stage.zone.color };
|
|
91
94
|
return step;
|
|
92
95
|
}
|
|
@@ -181,7 +184,7 @@ function scrubber(values, names) {
|
|
|
181
184
|
};
|
|
182
185
|
}
|
|
183
186
|
/** 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"]);
|
|
187
|
+
const STRUCTURAL = new Set(["format", "id", "kind", "goesTo", "next", "ifFails", "ifNotSure", "ifNoReply", "ifReplied", "thenMoveTo", "zone", "script", "language", "planning", "runIn", "waitFor", "method", "watch", "from", "delivery", "color", "merge"]);
|
|
185
188
|
function scrubDeep(value, scrub, key, inScripts) {
|
|
186
189
|
if (typeof value === "string")
|
|
187
190
|
return key !== null && (STRUCTURAL.has(key) || (inScripts && key === "name")) ? value : scrub(value);
|
|
@@ -272,7 +275,7 @@ function stageInputOf(step, index, find) {
|
|
|
272
275
|
const toOwner = kind === "approval" && typeof decider === "string" && decider.trim().toLowerCase() === "owner";
|
|
273
276
|
const anyone = decider === undefined || decider === null || (typeof decider === "string" && /^(anyone|any approver|)$/i.test(decider.trim()));
|
|
274
277
|
const at = object("at") ?? {};
|
|
275
|
-
const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifFails"];
|
|
278
|
+
const failKey = step["ifNotSure"] ?? step["ifNoReply"] ?? step["ifReplied"] ?? step["ifFails"];
|
|
276
279
|
const minutes = (value) => value === undefined ? undefined : durationMinutes(value) ?? refuse(`Zone ${title}: say a time like "3 days" (up to 30 days).`);
|
|
277
280
|
const sureAt = step["sureAt"];
|
|
278
281
|
return {
|
|
@@ -290,6 +293,7 @@ function stageInputOf(step, index, find) {
|
|
|
290
293
|
...(kind === "tool" ? { tool: { server: step["server"], name: step["tool"], args: typeof step["args"] === "object" && step["args"] !== null ? JSON.stringify(step["args"]) : step["args"] } } : {}),
|
|
291
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") } } : {}),
|
|
292
295
|
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"], title) })) } : {}),
|
|
293
297
|
...(step["remindAfter"] === undefined ? {} : { limit: { minutes: minutes(step["remindAfter"]), to: find(step["thenMoveTo"], title) } }),
|
|
294
298
|
next: find(step["next"], title), onFail: find(failKey, title),
|
|
295
299
|
};
|
package/dist/flow-steps.js
CHANGED
|
@@ -350,7 +350,7 @@ async function runCheck(store, flow, stage, script, card, now, io) {
|
|
|
350
350
|
return { state: "failed", said: `The project's setup failed before ${script.name} ran (exit ${prepared.code}).\n${tail(`${prepared.stdout}\n${prepared.stderr}`)}`, log, exitCode: prepared.code };
|
|
351
351
|
}
|
|
352
352
|
const definition = flowDefinitionOf(flow);
|
|
353
|
-
const titleOf = (id) => definition?.stages.find(one => one.id === id)?.title ?? id;
|
|
353
|
+
const titleOf = (id) => definition?.stages.find(one => one.id === id.split(".")[0])?.title ?? id;
|
|
354
354
|
// The card as data: never part of a command.
|
|
355
355
|
const input = {
|
|
356
356
|
card: { id: card.id, title: card.title, description: card.description, email: cardEmailOf(card) || null, note: card.note, owner: card.owner, source: card.source === null ? null : { kind: card.source.kind, label: card.source.label, url: card.source.url } },
|
package/dist/flows-cli.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { resolve } from 'node:path';
|
|
12
12
|
import { envelopeJson } from './envelope.js';
|
|
13
|
-
import { addCardToFlow, advanceFlows, flowDefinitionOf } from './flow-engine.js';
|
|
13
|
+
import { addCardToFlow, advanceFlows, crossProjectProblem, flowDefinitionOf } from './flow-engine.js';
|
|
14
14
|
import { saveScript } from './flow-scripts.js';
|
|
15
15
|
import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
|
|
16
16
|
import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
|
|
@@ -213,6 +213,9 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
213
213
|
const flowName = name || template?.label || '';
|
|
214
214
|
if (flowName === '')
|
|
215
215
|
return fail('usage', 'Name the flow with --name.', EXIT.usage);
|
|
216
|
+
const elsewhere = crossProjectProblem(store, definition, { repo: project, owner: who });
|
|
217
|
+
if (elsewhere !== null)
|
|
218
|
+
return refuse(project, 'invalid-steps', elsewhere);
|
|
216
219
|
const terms = flowTerms(definition, null);
|
|
217
220
|
if (template?.trigger !== undefined) {
|
|
218
221
|
const draft = { id: 0, repo: project, name: flowName, definitionJson: JSON.stringify(definition), revision: 1, state: 'active', createdBy: who, createdAt: now.toISOString(), updatedBy: who, updatedAt: now.toISOString(), owner: who };
|
|
@@ -377,6 +380,9 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
377
380
|
const redrawn = flowDigest(next) !== flowDigest(definition);
|
|
378
381
|
if (!redrawn && name === flow.name)
|
|
379
382
|
return fail('no-change', 'That\'s the flow as it is now.');
|
|
383
|
+
const elsewhere = crossProjectProblem(store, next, flow, who);
|
|
384
|
+
if (elsewhere !== null)
|
|
385
|
+
return refuse(flow.repo, 'invalid-steps', elsewhere);
|
|
380
386
|
if (!flags.has('yes'))
|
|
381
387
|
return preview(flow.repo, `Change the ${flow.name} flow`, [...(name === flow.name ? [] : [`Renames it to ${name}.`]), ...(redrawn ? flowTerms(next, definition) : [])], { flowId: flow.id, revision: flow.revision, definition: next });
|
|
382
388
|
if (!store.saveFlow(flow.id, { name, definitionJson: JSON.stringify(next), sawRevision: flow.revision, by: who }, now))
|
|
@@ -454,7 +460,7 @@ function listed(store, flow) {
|
|
|
454
460
|
const cards = store.flowCards(flow.id, false);
|
|
455
461
|
return { id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), zones: definition?.stages.map(one => one.title) ?? [],
|
|
456
462
|
triggers: store.flowTriggers(flow.id).filter(one => one.state === 'active').length, cardsWaiting: cards.length,
|
|
457
|
-
needDecision: cards.filter(card => definition?.stages.find(one => one.id === card.stage)?.kind
|
|
463
|
+
needDecision: cards.filter(card => ['approval', 'choose'].includes(definition?.stages.find(one => one.id === card.stage)?.kind ?? '')).length };
|
|
458
464
|
}
|
|
459
465
|
/** One zone and every path out of it, by zone id. */
|
|
460
466
|
function zoneOf(stage, flow) {
|
|
@@ -468,6 +474,8 @@ function zoneOf(stage, flow) {
|
|
|
468
474
|
...(stage.script === null ? {} : { script: stage.script }),
|
|
469
475
|
...(stage.merge === undefined ? {} : { merge: stage.merge }),
|
|
470
476
|
...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
|
|
477
|
+
...(stage.options === undefined ? {} : { options: stage.options.map(one => ({ label: one.label, to: one.to })) }),
|
|
478
|
+
...(stage.repo === undefined ? {} : { repo: stage.repo }),
|
|
471
479
|
};
|
|
472
480
|
}
|
|
473
481
|
function cardOf(definition, card) {
|
|
@@ -499,7 +507,7 @@ function showLines(flow) {
|
|
|
499
507
|
...('answers' in zone && zone.answers !== undefined ? zone.answers.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
|
|
500
508
|
...('routes' in zone && zone.routes !== undefined ? zone.routes.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
|
|
501
509
|
...(zone.next === null ? [] : [` next → ${title(zone.next)}`]),
|
|
502
|
-
...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
|
|
510
|
+
...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : zone.kind === 'choose' ? 'a reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
|
|
503
511
|
]),
|
|
504
512
|
'', 'Triggers',
|
|
505
513
|
...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.map(one => ` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`)),
|