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/chat-flow.js
CHANGED
|
@@ -16,13 +16,24 @@
|
|
|
16
16
|
* Every button is one opaque token for one visit of one card, on the part it
|
|
17
17
|
* rides: a tap on any other message, from anyone but the paired person, or
|
|
18
18
|
* for a card that moved on, changes nothing.
|
|
19
|
+
*
|
|
20
|
+
* A "Send to me" visit (flow-send.ts) is one notice with its links; a "Person
|
|
21
|
+
* chooses" visit is the same with the zone's own options as buttons. Instead
|
|
22
|
+
* of a tap, a reply in the notice's thread is the note for where replies go;
|
|
23
|
+
* the first other message in their chat is asked about once ("Use this as
|
|
24
|
+
* your note?"), and everything else goes to the lead as usual. Choices are
|
|
25
|
+
* made through chooseFlowCard, by that person alone, for exactly that visit,
|
|
26
|
+
* and spend that visit's buttons in every chat app.
|
|
19
27
|
*/
|
|
20
28
|
import { keptDraft } from "./flow-draft.js";
|
|
21
29
|
import { decideFlowCard, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
|
|
22
|
-
import {
|
|
30
|
+
import { chooseFlowCard, flowChoiceAt, flowSendPaths, FLOW_CHOOSE_KEY, FLOW_SEND_KEY, readFlowSend } from "./flow-send.js";
|
|
31
|
+
import { deciderOf, replyTarget } from "./flows.js";
|
|
23
32
|
import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
|
|
24
33
|
import { verifyApproverStanding } from "./principal.js";
|
|
34
|
+
import { chatHash } from "./chat-delivery-state.js";
|
|
25
35
|
import { FLOW_DECIDE_KEY, flowDecisionAt } from "./telegram-flow.js";
|
|
36
|
+
import { phoneCommand } from "./telegram-status.js";
|
|
26
37
|
export const CHAT_FLOW_LABELS = { approve: "Approve", edit: "Edit", "send-back": "Send back" };
|
|
27
38
|
const PART_CHARS = 2800;
|
|
28
39
|
const PROMPT_MS = 30 * 60_000;
|
|
@@ -56,24 +67,57 @@ function decisionParts(text, visit, link) {
|
|
|
56
67
|
const all = pieces(chatFlowText(text));
|
|
57
68
|
return all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, flow: visit, ...(link === null ? {} : { link }) });
|
|
58
69
|
}
|
|
70
|
+
/** A sent visit's links as a part's buttons: the first as its link, the rest beside it. */
|
|
71
|
+
function sendLinks(paths) {
|
|
72
|
+
return { ...(paths[0] === undefined ? {} : { link: paths[0] }), ...(paths.length > 1 ? { also: paths.slice(1) } : {}) };
|
|
73
|
+
}
|
|
74
|
+
/** A "Send to me" notice as chat parts: what was done, then its links; null when it isn't one. */
|
|
75
|
+
export function flowSendParts(store, notification) {
|
|
76
|
+
const visit = FLOW_SEND_KEY.exec(notification.dedupeKey);
|
|
77
|
+
const kept = visit === null ? null : store.flowSend(Number(visit[1]), Number(visit[2]));
|
|
78
|
+
const content = kept === null ? null : readFlowSend(kept.contentJson);
|
|
79
|
+
if (content === null)
|
|
80
|
+
return null;
|
|
81
|
+
const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
|
|
82
|
+
return all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, ...sendLinks(flowSendPaths(content)) });
|
|
83
|
+
}
|
|
59
84
|
/**
|
|
60
85
|
* A "flow-decision" notice as chat parts, or null when it isn't one (or the
|
|
61
86
|
* card has already moved on, and there is nothing left to decide).
|
|
62
87
|
*/
|
|
63
88
|
export function flowDecisionParts(store, notification) {
|
|
89
|
+
const choose = FLOW_CHOOSE_KEY.exec(notification.dedupeKey);
|
|
90
|
+
if (choose !== null) {
|
|
91
|
+
const visit = flowChoiceAt(store, Number(choose[1]), Number(choose[2]));
|
|
92
|
+
const kept = visit === null ? null : store.flowSend(visit.card.id, visit.card.entry);
|
|
93
|
+
const content = kept === null ? null : readFlowSend(kept.contentJson);
|
|
94
|
+
if (visit === null || content === null)
|
|
95
|
+
return null;
|
|
96
|
+
const options = (content.options ?? []).filter(one => visit.stage.options?.[one.choice]?.label === one.label);
|
|
97
|
+
const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
|
|
98
|
+
return all.map((piece, index) => index < all.length - 1 ? { text: piece }
|
|
99
|
+
: { text: piece, choose: { card: visit.card.id, entry: visit.card.entry, options }, ...sendLinks(flowSendPaths(content)) });
|
|
100
|
+
}
|
|
64
101
|
const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
|
|
65
102
|
const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
|
|
66
103
|
if (waiting === null)
|
|
67
104
|
return null;
|
|
68
105
|
return decisionParts(`${notification.subject}\n\n${notification.body}`, { card: waiting.card.id, entry: waiting.card.entry, actions: actionsFor(waiting) }, notification.link === null ? null : { label: "Open", path: notification.link });
|
|
69
106
|
}
|
|
70
|
-
/** The live buttons on one part, in the order they were minted
|
|
107
|
+
/** The live buttons on one part, in the order they were minted: a decision's, a choice's options in their order, or
|
|
108
|
+
* "Use this as your note?" Yes / No. `key` is unique on the part (Slack names each button by it). */
|
|
71
109
|
export function chatFlowButtons(state, part, now) {
|
|
72
|
-
|
|
73
|
-
.map(one => ({ token: String(one.token), action: one.action, label: CHAT_FLOW_LABELS[one.action] }));
|
|
110
|
+
const decide = state.prepare("SELECT token,action FROM chat_flow_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
|
|
111
|
+
.map(one => ({ token: String(one.token), action: one.action, label: CHAT_FLOW_LABELS[one.action], key: one.action.replace("-", "_") }));
|
|
112
|
+
const choose = state.prepare("SELECT token,choice,label FROM chat_flow_choice WHERE part=? AND consumed IS NULL AND expires>? ORDER BY choice").all(part, now.toISOString())
|
|
113
|
+
.map(one => ({ token: String(one.token), action: "choose", label: String(one.label), key: `choose_${Number(one.choice)}` }));
|
|
114
|
+
const note = state.prepare("SELECT token,answer FROM chat_flow_note WHERE part=? AND consumed IS NULL AND expires>? ORDER BY answer DESC").all(part, now.toISOString())
|
|
115
|
+
.map(one => ({ token: String(one.token), action: "note", label: one.answer === "yes" ? "Yes" : "No", key: `note_${one.answer}` }));
|
|
116
|
+
return [...decide, ...choose, ...note];
|
|
74
117
|
}
|
|
75
118
|
const retire = (state, card, entry, now) => {
|
|
76
119
|
state.prepare("UPDATE chat_flow_action SET consumed=? WHERE card=? AND entry=? AND consumed IS NULL").run(now.toISOString(), card, entry);
|
|
120
|
+
state.prepare("UPDATE chat_flow_choice SET consumed=? WHERE card=? AND entry=? AND consumed IS NULL").run(now.toISOString(), card, entry);
|
|
77
121
|
state.prepare("UPDATE chat_flow_prompt SET consumed=? WHERE card=? AND entry=? AND consumed IS NULL").run(now.toISOString(), card, entry);
|
|
78
122
|
};
|
|
79
123
|
/** Show the tapped notice again with what happened, and without its buttons. */
|
|
@@ -92,7 +136,7 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
|
|
|
92
136
|
const { store, state } = options;
|
|
93
137
|
const action = state.prepare("SELECT a.*,p.message,e.binding AS owner,e.channel FROM chat_flow_action a JOIN chat_part p ON p.id=a.part JOIN chat_event e ON e.id=p.event WHERE a.token=?").get(token);
|
|
94
138
|
if (action === undefined)
|
|
95
|
-
return
|
|
139
|
+
return applyChatChoiceTap(options, event, binding, token, repos, now) || applyChatNoteTap(options, event, binding, token, repos, now);
|
|
96
140
|
const say = (text) => state.plan(event.id, [{ text }], now);
|
|
97
141
|
// Bound to the person, the chat and the exact message the button rode.
|
|
98
142
|
if (Number(action["owner"]) !== binding.id || action["channel"] !== event.channel || action["message"] !== event.ts) {
|
|
@@ -144,6 +188,174 @@ export function applyChatFlowTap(options, event, binding, token, repos, now) {
|
|
|
144
188
|
: `What should change on “${waiting.card.title}”? Your next message here is the note, and the card goes back to ${back} with it. Send “cancel” to leave it where it is.`);
|
|
145
189
|
return true;
|
|
146
190
|
}
|
|
191
|
+
/** A tapped option of a "Person chooses" notice; false when the token isn't one. */
|
|
192
|
+
function applyChatChoiceTap(options, event, binding, token, repos, now) {
|
|
193
|
+
const { store, state } = options;
|
|
194
|
+
const tapped = state.prepare("SELECT c.*,p.message,e.binding AS owner,e.channel FROM chat_flow_choice c JOIN chat_part p ON p.id=c.part JOIN chat_event e ON e.id=p.event WHERE c.token=?").get(token);
|
|
195
|
+
if (tapped === undefined)
|
|
196
|
+
return false;
|
|
197
|
+
const say = (text) => state.plan(event.id, [{ text }], now);
|
|
198
|
+
// Bound to the person, the chat and the exact message the button rode.
|
|
199
|
+
if (Number(tapped["owner"]) !== binding.id || tapped["channel"] !== event.channel || tapped["message"] !== event.ts) {
|
|
200
|
+
say("That button expired or was already used. Ask for the current state before trying again.");
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (tapped["consumed"] !== null || String(tapped["expires"]) <= now.toISOString()) {
|
|
204
|
+
say("That was already chosen, or these buttons are too old.");
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
const card = Number(tapped["card"]), entry = Number(tapped["entry"]), part = Number(tapped["part"]);
|
|
208
|
+
const visit = flowChoiceAt(store, card, entry);
|
|
209
|
+
if (visit === null) {
|
|
210
|
+
retire(state, card, entry, now);
|
|
211
|
+
repaint(state, part, event, "This card has moved on since; nothing was changed.");
|
|
212
|
+
state.finish(event.id);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (visit.person !== binding.approver) {
|
|
216
|
+
say(`Only ${visit.person} chooses here.`);
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (!verifyApproverStanding(store, binding.approver, binding.generation, repos).ok) {
|
|
220
|
+
state.finish(event.id, true);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const label = String(tapped["label"]);
|
|
224
|
+
const chosen = chooseFlowCard(store, { card, entry, choice: Number(tapped["choice"]), label, note: null, actor: binding.approver, where: options.label, repos }, now);
|
|
225
|
+
if (!chosen.ok) {
|
|
226
|
+
say(chosen.message);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
retire(state, card, entry, now);
|
|
230
|
+
store.retireFlowChoices(card, entry, now);
|
|
231
|
+
repaint(state, part, event, `✅ You chose “${label}”. ${chosen.said}`);
|
|
232
|
+
state.finish(event.id);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
/** A reply that is the note for where a choice's replies go; `say` answers on the message it came from. */
|
|
236
|
+
function replyAsNote(options, binding, said, cutShort, visit, repos, now) {
|
|
237
|
+
const { store } = options;
|
|
238
|
+
if (said === "")
|
|
239
|
+
return { ok: false, said: "Say what you'd change." };
|
|
240
|
+
if (cutShort || said.length > 2000)
|
|
241
|
+
return { ok: false, said: "That's too long to take from here. Keep it under 2,000 characters, or reply in Toolroll." };
|
|
242
|
+
if (!verifyApproverStanding(store, binding.approver, binding.generation, repos).ok)
|
|
243
|
+
return null;
|
|
244
|
+
const chosen = chooseFlowCard(store, { card: visit.card, entry: visit.entry, choice: null, note: said, actor: binding.approver, where: options.label, repos }, now);
|
|
245
|
+
return chosen.ok ? { ok: true, said: chosen.said } : { ok: false, said: chosen.message };
|
|
246
|
+
}
|
|
247
|
+
/** The choice a part's message carries, by the message any of its parts was posted as (a long notice is several). */
|
|
248
|
+
function choiceOnMessage(state, binding, channel, message) {
|
|
249
|
+
const row = state.prepare(`SELECT c.card,c.entry,MAX(c.consumed IS NULL) AS live FROM chat_part p JOIN chat_event e ON e.id=p.event
|
|
250
|
+
JOIN chat_part q ON q.event=p.event JOIN chat_flow_choice c ON c.part=q.id WHERE e.binding=? AND e.channel=? AND p.message=? GROUP BY c.card,c.entry ORDER BY MAX(c.rowid) DESC LIMIT 1`)
|
|
251
|
+
.get(binding.id, channel, message);
|
|
252
|
+
return row === undefined ? null : { card: Number(row["card"]), entry: Number(row["entry"]), live: Number(row["live"]) === 1 };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* A message in the person's own chat while one of their choices waits. Only a reply in the Choose notice's thread (Slack,
|
|
256
|
+
* Discord) is the note. Any other message there is the lead's, except the first one after a choice that takes replies:
|
|
257
|
+
* that one is asked about, once — "Use this as your note?" — and only Yes makes it the note (No passes it on to the lead).
|
|
258
|
+
* false when the message isn't about a choice at all.
|
|
259
|
+
*/
|
|
260
|
+
export function answerChoiceMessage(options, event, binding, input, now, repos) {
|
|
261
|
+
const { store, state } = options;
|
|
262
|
+
if (event.kind !== "message" || event.channel !== binding.channel || input.lead === true)
|
|
263
|
+
return false;
|
|
264
|
+
const say = (text) => state.plan(event.id, [{ text }], now);
|
|
265
|
+
const said = input.text.trim();
|
|
266
|
+
return store.transact(() => {
|
|
267
|
+
if (event.thread !== "" && event.thread !== event.ts) {
|
|
268
|
+
const on = choiceOnMessage(state, binding, event.channel, event.thread);
|
|
269
|
+
if (on === null)
|
|
270
|
+
return false;
|
|
271
|
+
const visit = flowChoiceAt(store, on.card, on.entry);
|
|
272
|
+
if (visit === null || !on.live) {
|
|
273
|
+
say("That choice was already made, or the card moved on; nothing was changed.");
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (visit.person !== binding.approver) {
|
|
277
|
+
say(`Only ${visit.person} chooses here.`);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (replyTarget(visit.stage) === null) {
|
|
281
|
+
say("This step takes one of its options. Tap one above.");
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
const done = replyAsNote(options, binding, said, (input.originalLength ?? 0) > input.text.length, on, repos, now);
|
|
285
|
+
if (done === null)
|
|
286
|
+
state.finish(event.id, true);
|
|
287
|
+
else
|
|
288
|
+
say(done.ok ? `↩️ ${done.said}` : done.said);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
if (said === "" || phoneCommand(said) !== null)
|
|
292
|
+
return false;
|
|
293
|
+
// The newest choice of theirs still open here that takes a reply and hasn't asked yet.
|
|
294
|
+
const open = state.prepare(`SELECT DISTINCT c.card,c.entry FROM chat_flow_choice c JOIN chat_part p ON p.id=c.part JOIN chat_event e ON e.id=p.event
|
|
295
|
+
WHERE e.binding=? AND c.consumed IS NULL AND c.expires>? AND NOT EXISTS (SELECT 1 FROM chat_flow_note n WHERE n.card=c.card AND n.entry=c.entry) ORDER BY c.rowid DESC`)
|
|
296
|
+
.all(binding.id, now.toISOString());
|
|
297
|
+
for (const one of open) {
|
|
298
|
+
const visit = flowChoiceAt(store, Number(one.card), Number(one.entry));
|
|
299
|
+
const target = visit === null ? null : replyTarget(visit.stage);
|
|
300
|
+
if (visit === null || target === null || visit.person !== binding.approver)
|
|
301
|
+
continue;
|
|
302
|
+
const to = visit.definition.stages.find(stage => stage.id === target)?.title ?? target;
|
|
303
|
+
state.plan(event.id, [{ text: `Use this as your note on “${visit.card.title}”?\nYes sends it to ${to}. No passes it to the lead.`,
|
|
304
|
+
note: { card: visit.card.id, entry: visit.card.entry, held: event.id } }], now);
|
|
305
|
+
// What it said is kept beside its question until answered: the event's own copy is cleared once it is handled.
|
|
306
|
+
state.prepare("UPDATE chat_flow_note SET words=? WHERE held=?").run(JSON.stringify({ text: input.text, ...(input.originalLength === undefined ? {} : { originalLength: input.originalLength }) }), event.id);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Yes or No to "Use this as your note?"; false when the token isn't one. */
|
|
313
|
+
function applyChatNoteTap(options, event, binding, token, repos, now) {
|
|
314
|
+
const { store, state } = options;
|
|
315
|
+
const tapped = state.prepare("SELECT n.*,p.message,e.binding AS owner,e.channel FROM chat_flow_note n JOIN chat_part p ON p.id=n.part JOIN chat_event e ON e.id=p.event WHERE n.token=?").get(token);
|
|
316
|
+
if (tapped === undefined)
|
|
317
|
+
return false;
|
|
318
|
+
const say = (text) => state.plan(event.id, [{ text }], now);
|
|
319
|
+
if (Number(tapped["owner"]) !== binding.id || tapped["channel"] !== event.channel || tapped["message"] !== event.ts) {
|
|
320
|
+
say("That button expired or was already used. Ask for the current state before trying again.");
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
const part = Number(tapped["part"]), card = Number(tapped["card"]), entry = Number(tapped["entry"]);
|
|
324
|
+
if (tapped["consumed"] !== null || String(tapped["expires"]) <= now.toISOString()) {
|
|
325
|
+
repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.");
|
|
326
|
+
state.finish(event.id);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
state.prepare("UPDATE chat_flow_note SET consumed=?,words=NULL WHERE part=?").run(now.toISOString(), part);
|
|
330
|
+
const held = state.event(String(tapped["held"]));
|
|
331
|
+
const words = typeof tapped["words"] === "string" ? JSON.parse(tapped["words"]) : null;
|
|
332
|
+
if (held === null || words === null || typeof words.text !== "string") {
|
|
333
|
+
repaint(state, part, event, "That message is no longer kept; nothing was changed.");
|
|
334
|
+
state.finish(event.id);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (tapped["answer"] === "no") {
|
|
338
|
+
// Passed on as the message it was, to the lead, once.
|
|
339
|
+
state.enqueue({ id: chatHash(`${held.id}:lead`), installation: held.installation, binding: held.binding, kind: "message", channel: held.channel, member: held.member,
|
|
340
|
+
ts: held.ts, thread: held.thread, payload: JSON.stringify({ ...words, lead: true }), created: now.toISOString() });
|
|
341
|
+
repaint(state, part, event, "Passed to the lead.");
|
|
342
|
+
state.finish(event.id);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (flowChoiceAt(store, card, entry) === null) {
|
|
346
|
+
repaint(state, part, event, "That choice was already made, or the card moved on; nothing was changed.");
|
|
347
|
+
state.finish(event.id);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
const done = replyAsNote(options, binding, words.text.trim(), typeof words.originalLength === "number" && words.originalLength > words.text.length, { card, entry }, repos, now);
|
|
351
|
+
if (done === null) {
|
|
352
|
+
state.finish(event.id, true);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
repaint(state, part, event, done.ok ? `↩️ ${done.said}` : done.said);
|
|
356
|
+
state.finish(event.id);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
147
359
|
/**
|
|
148
360
|
* A message from the person while a prompt is open: the new draft or the
|
|
149
361
|
* note. false when no prompt is open (the message goes on as usual).
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
+
error?: never;
|
|
128
129
|
version?: never;
|
|
129
130
|
active: boolean;
|
|
130
131
|
phase: string;
|
|
@@ -132,7 +133,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
132
133
|
running: boolean;
|
|
133
134
|
canResume: boolean;
|
|
134
135
|
canCancel: boolean;
|
|
135
|
-
error?: never;
|
|
136
136
|
automaticRecovery?: never;
|
|
137
137
|
recoveryAttempts?: never;
|
|
138
138
|
recoveryRepairs?: never;
|
package/dist/discord-chat.js
CHANGED
|
@@ -284,11 +284,11 @@ export async function deliverDiscordPart(options) {
|
|
|
284
284
|
else if (!content.image)
|
|
285
285
|
buttons = [
|
|
286
286
|
// A flow decision (v88): Approve / Edit / Send back, then the link.
|
|
287
|
-
...(content.flow
|
|
287
|
+
...(content.flow || content.choose || content.note
|
|
288
288
|
? chatFlowButtons(state, row.id, now).map((one) => ({
|
|
289
289
|
type: 2,
|
|
290
290
|
style: one.action === "approve" ? 3 : 2,
|
|
291
|
-
label: one.label,
|
|
291
|
+
label: one.label.slice(0, 80),
|
|
292
292
|
custom_id: `so_${one.token}`,
|
|
293
293
|
}))
|
|
294
294
|
: []),
|
package/dist/evidence.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* cannot pre-write them — created exclusively (`wx`) with mode 0600. The
|
|
14
14
|
* store holds keys relative to that root, never absolute paths.
|
|
15
15
|
*/
|
|
16
|
-
import { type ParsedReport } from "./scout-report.js";
|
|
16
|
+
import { type ParsedReport, type ReportImage } from "./scout-report.js";
|
|
17
17
|
import { type ParsedProof } from "./proof.js";
|
|
18
18
|
import type { Artifact, Store } from "./store.js";
|
|
19
19
|
import type { ExecResult } from "./exec.js";
|
|
@@ -108,11 +108,23 @@ export type ReportView = {
|
|
|
108
108
|
ok: true;
|
|
109
109
|
run: number;
|
|
110
110
|
report: ParsedReport;
|
|
111
|
+
shots: ReportShot[];
|
|
111
112
|
} | {
|
|
112
113
|
ok: false;
|
|
113
114
|
run: number;
|
|
114
115
|
problem: string;
|
|
115
116
|
};
|
|
117
|
+
/** A report's screenshot as a page shows it: its stored evidence, or why it can't be shown. The bytes are
|
|
118
|
+
* verified again whenever the image itself is served. */
|
|
119
|
+
export type ReportShot = {
|
|
120
|
+
file: string;
|
|
121
|
+
caption: string;
|
|
122
|
+
url: string;
|
|
123
|
+
artifactId: number | null;
|
|
124
|
+
problem: string | null;
|
|
125
|
+
};
|
|
126
|
+
/** Each image a report names, matched to the screenshot evidence its own run stored with the same sha256. */
|
|
127
|
+
export declare function reportShotsOf(artifacts: readonly Artifact[], root: string, run: number, images: readonly ReportImage[]): ReportShot[];
|
|
116
128
|
export declare function readVerifiedReport(store: Store, root: string, taskRef: number): ReportView | null;
|
|
117
129
|
/** A build's proof, read the same verified way as a scout's report — but
|
|
118
130
|
* keyed by RUN, not task: unlike a scout's report (the task's one rolling
|
package/dist/evidence.js
CHANGED
|
@@ -227,6 +227,15 @@ export function readMailbox(path, cap = LIMITS.payload) {
|
|
|
227
227
|
closeSync(fd);
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
/** Each image a report names, matched to the screenshot evidence its own run stored with the same sha256. */
|
|
231
|
+
export function reportShotsOf(artifacts, root, run, images) {
|
|
232
|
+
const pruned = existsSync(join(root, String(run), RETENTION_NOTE));
|
|
233
|
+
return images.map(image => {
|
|
234
|
+
const stored = artifacts.find(one => one.id === image.artifact && one.run === run && one.kind === "screenshot" && one.sha256 === image.sha256);
|
|
235
|
+
const problem = pruned ? "removed by the retention setting" : stored === undefined ? "the saved screenshot is missing" : null;
|
|
236
|
+
return { file: image.file, caption: image.caption, url: image.url, artifactId: problem === null ? stored.id : null, problem };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
230
239
|
export function readVerifiedReport(store, root, taskRef) {
|
|
231
240
|
const artifact = store.latestReportArtifact(taskRef);
|
|
232
241
|
if (artifact === null)
|
|
@@ -243,7 +252,7 @@ export function readVerifiedReport(store, root, taskRef) {
|
|
|
243
252
|
const parsed = parseReport(verified.content.toString("utf8"));
|
|
244
253
|
if (!parsed.ok)
|
|
245
254
|
return { ok: false, run: artifact.run, problem: "the stored report is not a report this build can read" };
|
|
246
|
-
return { ok: true, run: artifact.run, report: parsed.report };
|
|
255
|
+
return { ok: true, run: artifact.run, report: parsed.report, shots: reportShotsOf(store.artifactsFor(artifact.run), root, artifact.run, parsed.report.images) };
|
|
247
256
|
}
|
|
248
257
|
/** Verifies this run's proof bytes and format. A handoff that relies on a
|
|
249
258
|
* revision's ancestors must also recheck its saved review-context custody. */
|
package/dist/flow-engine.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export declare function flowGoalCuts(store: Store, taskId: string, goal: string)
|
|
|
22
22
|
label: string;
|
|
23
23
|
text: string;
|
|
24
24
|
}[];
|
|
25
|
+
/** Why a flow can't be saved as drawn: a build zone in a project its owner, or whoever saves it, may not file work in; null when none. */
|
|
26
|
+
export declare function crossProjectProblem(store: Store, definition: FlowDefinition, flow: {
|
|
27
|
+
repo: string;
|
|
28
|
+
owner: string;
|
|
29
|
+
}, editor?: string): string | null;
|
|
25
30
|
export type FlowAct = {
|
|
26
31
|
ok: true;
|
|
27
32
|
said: string;
|
|
@@ -57,6 +62,8 @@ export type FlowDecision = {
|
|
|
57
62
|
/** A person's decision on a card waiting in an approval zone. A send back to a build zone whose work has a result becomes a revision of that work. */
|
|
58
63
|
/** The draft a decision is about: the draft zone that sends its cards to this one. */
|
|
59
64
|
export declare function draftFor(definition: FlowDefinition, stage: FlowStage): FlowStage | null;
|
|
65
|
+
/** Back to a build zone with a finished result: a revision of that same work, carrying the note; null when it isn't one. */
|
|
66
|
+
export declare function revisionWithNote(store: Store, card: FlowCardRow, target: FlowStage | undefined, note: string, actor: string, repos: readonly string[], evidenceRoot: string | undefined, now: Date): string | null;
|
|
60
67
|
export declare function decideFlowCard(store: Store, input: {
|
|
61
68
|
card: number;
|
|
62
69
|
decision: "approve" | "send-back";
|
package/dist/flow-engine.js
CHANGED
|
@@ -9,13 +9,14 @@ import { assignmentOf } from "./assignment.js";
|
|
|
9
9
|
import { filerFor } from "./approval-policy.js";
|
|
10
10
|
import { diagnoseTaskDispatch } from "./dispatch.js";
|
|
11
11
|
import { deciderOf, durationWords, fillFlowText, fitFlowText, flowWorkTemplate, validateFlowDefinition, withinHours } from "./flows.js";
|
|
12
|
-
import { reportSummaryFor } from "./report-summary.js";
|
|
12
|
+
import { reportFillIns, reportSummaryFor } from "./report-summary.js";
|
|
13
13
|
import { fileTaskProposal } from "./proposal.js";
|
|
14
14
|
import { requestResultChanges } from "./result-actions.js";
|
|
15
15
|
import { revisionSourceOf } from "./result-review.js";
|
|
16
|
-
import { scanForSecrets } from "./evidence.js";
|
|
16
|
+
import { readVerifiedReport, scanForSecrets } from "./evidence.js";
|
|
17
17
|
import { cardFollowers, notifyPeople } from "./flow-people.js";
|
|
18
18
|
import { keptDraft } from "./flow-draft.js";
|
|
19
|
+
import { chooseStep, flowPersonOf, sendStep } from "./flow-send.js";
|
|
19
20
|
import { parseSoul, teammateLabel } from "./teammates.js";
|
|
20
21
|
/** The flow's drawing, or null when it can't be read (the card then says so and waits). */
|
|
21
22
|
export function flowDefinitionOf(flow) {
|
|
@@ -38,6 +39,7 @@ function fill(template, card) {
|
|
|
38
39
|
return fillFlowText(template, { title: card.title, description: card.description, note: card.note, outputs: card.outputs });
|
|
39
40
|
}
|
|
40
41
|
const titleIn = (definition, id) => definition.stages.find(one => one.id === id)?.title ?? id;
|
|
42
|
+
const flowDefinitionStage = (definition, id) => definition.stages.find(one => one.id === id);
|
|
41
43
|
/** A teammate's name as its soul file gives it (its handle when the file can't be read). */
|
|
42
44
|
function teammateName(mate) {
|
|
43
45
|
const read = parseSoul(mate.soul);
|
|
@@ -109,7 +111,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
|
|
|
109
111
|
if (stage.limit !== undefined && stage.kind !== "wait" && stage.kind !== "done") {
|
|
110
112
|
const entered = Date.parse(store.flowCardEnteredAt(card.id) ?? card.updatedAt);
|
|
111
113
|
if (now.getTime() >= entered + stage.limit.minutes * 60_000) {
|
|
112
|
-
const decider = stage.kind === "approval" ? deciderOf(stage, flow) : null;
|
|
114
|
+
const decider = stage.kind === "approval" ? deciderOf(stage, flow) : stage.kind === "choose" ? flowPersonOf(card, flow) : null;
|
|
113
115
|
const to = stage.limit.to;
|
|
114
116
|
notifyPeople(store, card, [decider ?? card.owner ?? flow.owner], null, { key: `limit:${card.entry}`, attention: true,
|
|
115
117
|
subject: `“${card.title}” has waited ${durationWords(stage.limit.minutes)} in ${stage.title}`,
|
|
@@ -212,9 +214,18 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
|
|
|
212
214
|
}
|
|
213
215
|
return;
|
|
214
216
|
}
|
|
217
|
+
case "send":
|
|
218
|
+
// What the step before produced, to the card's person in each chat app they paired and on the card; then on.
|
|
219
|
+
sendStep(store, flow, definition, stage, card, now, options.evidenceRoot);
|
|
220
|
+
onward("ok");
|
|
221
|
+
return;
|
|
222
|
+
case "choose":
|
|
223
|
+
// The same, with the zone's options as buttons: the card waits for the person's choice (chooseFlowCard).
|
|
224
|
+
chooseStep(store, flow, definition, stage, card, now, options.evidenceRoot);
|
|
225
|
+
return;
|
|
215
226
|
case "task":
|
|
216
227
|
case "report":
|
|
217
|
-
workStage(store, flow, stage, card, now, options, outcome, onward);
|
|
228
|
+
workStage(store, flow, definition, stage, card, now, options, outcome, onward);
|
|
218
229
|
return;
|
|
219
230
|
case "check":
|
|
220
231
|
case "pull-request":
|
|
@@ -228,7 +239,9 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
|
|
|
228
239
|
return;
|
|
229
240
|
}
|
|
230
241
|
}
|
|
231
|
-
function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
242
|
+
function workStage(store, flow, definition, stage, card, now, options, outcome, onward) {
|
|
243
|
+
// A build may work in another project (its zone's repo), one the flow's owner may file in; research stays in the flow's.
|
|
244
|
+
const repo = stage.kind === "task" && stage.repo !== undefined ? stage.repo : flow.repo;
|
|
232
245
|
if (card.task === null) {
|
|
233
246
|
const report = stage.kind === "report";
|
|
234
247
|
// Waiting cards try again every pass, so a cause that's fixed (a shorter zone, a freed backlog) files the work then.
|
|
@@ -241,6 +254,10 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
241
254
|
wait(`${stage.title}'s instructions are too long for a task. Open the flow and shorten them.`);
|
|
242
255
|
return;
|
|
243
256
|
}
|
|
257
|
+
if (repo !== flow.repo && !store.accountCanAccess(flow.owner, repo)) {
|
|
258
|
+
wait(`${flow.owner}, who owns this flow, can't file work in ${repo}. Choose another project for ${stage.title}, or give them access.`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
244
261
|
// Filed exactly once however many passes advance this project: the card is claimed (still this entry, in this zone,
|
|
245
262
|
// with no task) in the same transaction that files its task, so another pass finds it claimed and leaves it.
|
|
246
263
|
const filed = store.transact(() => {
|
|
@@ -249,13 +266,13 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
249
266
|
return null;
|
|
250
267
|
const made = fileTaskProposal(store, {
|
|
251
268
|
title: (report ? `${stage.title}: ${card.title}` : card.title).slice(0, 200),
|
|
252
|
-
repo
|
|
269
|
+
repo,
|
|
253
270
|
goal,
|
|
254
271
|
filedVia: `flow:${flow.id}`, filedBy: filerFor(card.createdBy), // who made the card asked for the work (an owner can be reassigned by anyone)
|
|
255
272
|
deliverable: report ? "report" : "branch",
|
|
256
273
|
planning: report ? "skip" : stage.planning ?? "auto",
|
|
257
274
|
acceptance: report ? ACCEPTANCE.report : ACCEPTANCE.task,
|
|
258
|
-
admittedRepos: [
|
|
275
|
+
admittedRepos: [repo],
|
|
259
276
|
}, now);
|
|
260
277
|
if (made.ok)
|
|
261
278
|
store.updateFlowCard(card.id, { task: made.id, ...(report || fresh.primaryTask !== null ? {} : { primaryTask: made.id }), waiting: "Filed as a task" }, now);
|
|
@@ -271,7 +288,7 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
271
288
|
return;
|
|
272
289
|
}
|
|
273
290
|
// A send back may have made a revision: follow the task's current version.
|
|
274
|
-
const family = store.taskFamilyOf(card.task, [
|
|
291
|
+
const family = store.taskFamilyOf(card.task, [repo], false);
|
|
275
292
|
const current = family?.current.id ?? card.task;
|
|
276
293
|
const task = store.getTask(current);
|
|
277
294
|
if (task === null) {
|
|
@@ -281,7 +298,7 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
281
298
|
if (task.state === "done") {
|
|
282
299
|
// A build whose project checks failed is done but not good: it takes the failure path, like a failed build.
|
|
283
300
|
if (stage.kind === "task") {
|
|
284
|
-
const checks = assignmentOf(store, current, now, { principal: "operator", repos: [
|
|
301
|
+
const checks = assignmentOf(store, current, now, { principal: "operator", repos: [repo] }, options.evidenceRoot)?.receipt?.checks ?? null;
|
|
285
302
|
if (checks?.status === "failed") {
|
|
286
303
|
const said = `The checks failed on its result: ${checks.detail}`;
|
|
287
304
|
store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: `${said} (task ${current})` }, primaryTask: current }, now);
|
|
@@ -297,12 +314,24 @@ function workStage(store, flow, stage, card, now, options, outcome, onward) {
|
|
|
297
314
|
const ref = store.lookupRef(current);
|
|
298
315
|
const summary = ref === null ? null : reportSummaryFor(store, options.evidenceRoot, ref.id);
|
|
299
316
|
outputs[stage.id] = summary !== null && "summary" in summary ? summary.summary.slice(0, 6000) : "The report is ready on its task.";
|
|
317
|
+
// {{stage.<id>.items}} and {{stage.<id>.report}}: what it found and the whole report, for the zones after it.
|
|
318
|
+
// A visit's report replaces the last visit's, so nothing stale is left behind.
|
|
319
|
+
const view = ref === null || options.evidenceRoot === undefined ? null : readVerifiedReport(store, options.evidenceRoot, ref.id);
|
|
320
|
+
const found = view?.ok === true ? reportFillIns(view.report) : { items: "", report: "" };
|
|
321
|
+
for (const [part, text] of Object.entries(found)) {
|
|
322
|
+
if (text === "")
|
|
323
|
+
delete outputs[`${stage.id}.${part}`];
|
|
324
|
+
else
|
|
325
|
+
outputs[`${stage.id}.${part}`] = text;
|
|
326
|
+
}
|
|
300
327
|
}
|
|
301
328
|
else {
|
|
302
329
|
outputs[stage.id] = `Result ready on task ${current}.`;
|
|
303
330
|
}
|
|
304
331
|
store.updateFlowCard(card.id, { outputs, ...(stage.kind === "task" ? { primaryTask: current } : {}), waiting: null }, now);
|
|
305
|
-
|
|
332
|
+
// A "Send to me" or "Person chooses" zone next reads what this task produced: the task goes with the card there.
|
|
333
|
+
const following = stage.next === null ? undefined : flowDefinitionStage(definition, stage.next);
|
|
334
|
+
onward("ok", undefined, following?.kind === "send" || following?.kind === "choose" ? current : undefined);
|
|
306
335
|
return;
|
|
307
336
|
}
|
|
308
337
|
if (task.state === "failed" || task.state === "cancelled") {
|
|
@@ -343,17 +372,27 @@ function goalCuts(store, taskId, goal) {
|
|
|
343
372
|
const labels = { "card.title": "The card's title", "card.description": "The card's description", note: "The note it was sent back with" };
|
|
344
373
|
const cuts = [];
|
|
345
374
|
const seen = new Set();
|
|
346
|
-
for (const match of flowWorkTemplate(stage.instructions ?? card.title, card).matchAll(/\{\{\s*(card\.title|card\.description|note|stage\.([a-z0-9-]+))\s*\}\}/g)) {
|
|
375
|
+
for (const match of flowWorkTemplate(stage.instructions ?? card.title, card).matchAll(/\{\{\s*(card\.title|card\.description|note|stage\.([a-z0-9-]+)(?:\.(items|report))?)\s*\}\}/g)) {
|
|
347
376
|
if (seen.has(match[1]))
|
|
348
377
|
continue;
|
|
349
378
|
seen.add(match[1]);
|
|
350
379
|
const text = fillFlowText(`{{${match[1]}}}`, card);
|
|
351
380
|
if (text === "" || goal.includes(text))
|
|
352
381
|
continue;
|
|
353
|
-
|
|
382
|
+
const part = match[3] === "items" ? "listed" : match[3] === "report" ? "reported in full" : "found";
|
|
383
|
+
cuts.push({ label: labels[match[1]] ?? `What ${titleIn(definition, match[2])} ${part}`, text: text.slice(0, 50_000) });
|
|
354
384
|
}
|
|
355
385
|
return cuts;
|
|
356
386
|
}
|
|
387
|
+
/** Why a flow can't be saved as drawn: a build zone in a project its owner, or whoever saves it, may not file work in; null when none. */
|
|
388
|
+
export function crossProjectProblem(store, definition, flow, editor = flow.owner) {
|
|
389
|
+
const elsewhere = definition.stages.filter((one) => one.kind === "task" && one.repo !== undefined && one.repo !== flow.repo);
|
|
390
|
+
const owners = elsewhere.find(one => !store.accountCanAccess(flow.owner, one.repo));
|
|
391
|
+
if (owners !== undefined)
|
|
392
|
+
return `Zone ${owners.title}: ${flow.owner}, who owns this flow, can't file work in that project.`;
|
|
393
|
+
const editors = elsewhere.find(one => !store.accountCanAccess(editor, one.repo));
|
|
394
|
+
return editors === undefined ? null : `Zone ${editors.title}: you can't file work in that project, so you can't point a build there.`;
|
|
395
|
+
}
|
|
357
396
|
const zoneWords = (title) => `${title}${/[.?!]$/.test(title) ? "" : "."}`;
|
|
358
397
|
/** A card's words, checked once for every door: a title, an optional description, and no keys, because cards become agent instructions. */
|
|
359
398
|
export function flowCardText(title, description) {
|
|
@@ -411,6 +450,25 @@ export function draftFor(definition, stage) {
|
|
|
411
450
|
// A teammate's zone that leads here (by its next, or one of its answers) wrote the draft this decision checks (starter kits).
|
|
412
451
|
?? definition.stages.find(one => one.kind === "teammate" && (one.next === stage.id || (one.routes ?? []).some(route => route.to === stage.id))) ?? null;
|
|
413
452
|
}
|
|
453
|
+
/** Back to a build zone with a finished result: a revision of that same work, carrying the note; null when it isn't one. */
|
|
454
|
+
export function revisionWithNote(store, card, target, note, actor, repos, evidenceRoot, now) {
|
|
455
|
+
if (target?.kind !== "task" || card.primaryTask === null || evidenceRoot === undefined)
|
|
456
|
+
return null;
|
|
457
|
+
// The build's own project (a zone may build in another) must be one the person can reach.
|
|
458
|
+
const repo = store.lookupRef(card.primaryTask)?.repo ?? null;
|
|
459
|
+
if (repo !== null && !repos.includes(repo))
|
|
460
|
+
return null;
|
|
461
|
+
const snapshot = assignmentOf(store, card.primaryTask, now, { principal: "operator", repos });
|
|
462
|
+
const runId = snapshot?.receipt?.runId;
|
|
463
|
+
const runTask = runId === undefined ? null : store.externalIdFor(store.getRun(runId)?.taskRef ?? -1);
|
|
464
|
+
if (runId === undefined || runTask === null)
|
|
465
|
+
return null;
|
|
466
|
+
const revised = requestResultChanges(store, evidenceRoot, {
|
|
467
|
+
run: runId, source: revisionSourceOf(store.getScope(runTask)?.digest ?? null), actor, repos,
|
|
468
|
+
batch: "", note, path: "", line: "", request: `flow-${card.id}-${card.entry}`, allowMode: true,
|
|
469
|
+
}, now);
|
|
470
|
+
return revised.ok ? revised.id : null;
|
|
471
|
+
}
|
|
414
472
|
export function decideFlowCard(store, input, now) {
|
|
415
473
|
const card = store.getFlowCard(input.card);
|
|
416
474
|
const flow = card === null ? null : store.getFlow(card.flow);
|
|
@@ -449,21 +507,7 @@ export function decideFlowCard(store, input, now) {
|
|
|
449
507
|
if (input.note === null || input.note.trim() === "")
|
|
450
508
|
return { ok: false, message: "Say what should change." };
|
|
451
509
|
const target = definition.stages.find(one => one.id === stage.onFail);
|
|
452
|
-
|
|
453
|
-
let revision = null;
|
|
454
|
-
if (target?.kind === "task" && card.primaryTask !== null && input.evidenceRoot !== undefined) {
|
|
455
|
-
const snapshot = assignmentOf(store, card.primaryTask, now, { principal: "operator", repos: input.repos });
|
|
456
|
-
const runId = snapshot?.receipt?.runId;
|
|
457
|
-
const runTask = runId === undefined ? null : store.externalIdFor(store.getRun(runId)?.taskRef ?? -1);
|
|
458
|
-
if (runId !== undefined && runTask !== null) {
|
|
459
|
-
const revised = requestResultChanges(store, input.evidenceRoot, {
|
|
460
|
-
run: runId, source: revisionSourceOf(store.getScope(runTask)?.digest ?? null), actor: input.actor, repos: input.repos,
|
|
461
|
-
batch: "", note: input.note.trim(), path: "", line: "", request: `flow-${card.id}-${card.entry}`, allowMode: true,
|
|
462
|
-
}, now);
|
|
463
|
-
if (revised.ok)
|
|
464
|
-
revision = revised.id;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
510
|
+
const revision = revisionWithNote(store, card, target, input.note.trim(), input.actor, input.repos, input.evidenceRoot, now);
|
|
467
511
|
store.moveFlowCard(card.id, { to: stage.onFail, outcome: "sent-back", actor: input.actor, note: input.note.trim(), ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now);
|
|
468
512
|
if (card.owner !== null)
|
|
469
513
|
notifyPeople(store, card, [card.owner], input.actor, { key: `sent-back:${card.entry}`, subject: `${input.actor} sent “${card.title}” back`, body: `Sent back to ${target?.title ?? stage.onFail}:\n\n${input.note.trim()}`, attention: true }, now);
|
|
@@ -4,7 +4,7 @@
|
|
|
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
6
|
import { type GalleryAnswers, type GalleryPreview, type GalleryTemplate } from "./flow-gallery.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type FlowDefinition } from "./flows.js";
|
|
8
8
|
export declare const GALLERY_CSS: string;
|
|
9
9
|
/** A small drawing of a flow's zones where they sit on its canvas, with its paths; a person's decisions stand out. */
|
|
10
10
|
export declare function zonesDiagram(definition: FlowDefinition, label: string): string;
|