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.
Files changed (55) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +41 -1
  5. package/dist/chat-actions.d.ts +5 -0
  6. package/dist/chat-actions.js +57 -6
  7. package/dist/chat-delivery-state.d.ts +15 -0
  8. package/dist/chat-delivery-state.js +27 -2
  9. package/dist/chat-delivery.js +24 -4
  10. package/dist/chat-flow.d.ts +26 -3
  11. package/dist/chat-flow.js +217 -5
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/discord-chat.js +2 -2
  14. package/dist/evidence.d.ts +13 -1
  15. package/dist/evidence.js +10 -1
  16. package/dist/flow-engine.d.ts +7 -0
  17. package/dist/flow-engine.js +71 -27
  18. package/dist/flow-gallery-ui.d.ts +1 -1
  19. package/dist/flow-gallery-ui.js +8 -7
  20. package/dist/flow-gallery.d.ts +8 -0
  21. package/dist/flow-gallery.js +37 -3
  22. package/dist/flow-send.d.ts +80 -0
  23. package/dist/flow-send.js +249 -0
  24. package/dist/flow-share.js +7 -3
  25. package/dist/flow-steps.js +1 -1
  26. package/dist/flows-cli.js +11 -3
  27. package/dist/flows-ui.js +20 -4
  28. package/dist/flows.d.ts +27 -1
  29. package/dist/flows.js +104 -18
  30. package/dist/mate-tools.js +33 -10
  31. package/dist/operate.js +41 -0
  32. package/dist/project-delete.js +4 -0
  33. package/dist/project-demo.d.ts +12 -0
  34. package/dist/project-demo.js +71 -0
  35. package/dist/provider.d.ts +6 -0
  36. package/dist/provider.js +4 -1
  37. package/dist/report-summary.d.ts +9 -0
  38. package/dist/report-summary.js +9 -0
  39. package/dist/result-shots.d.ts +2 -0
  40. package/dist/result-shots.js +24 -5
  41. package/dist/scout-net.d.ts +22 -0
  42. package/dist/scout-net.js +214 -0
  43. package/dist/scout-report.d.ts +67 -0
  44. package/dist/scout-report.js +108 -1
  45. package/dist/scout.d.ts +42 -0
  46. package/dist/scout.js +225 -16
  47. package/dist/serve.js +43 -5
  48. package/dist/slack-chat.js +3 -3
  49. package/dist/store.d.ts +53 -0
  50. package/dist/store.js +95 -4
  51. package/dist/teams-chat.js +1 -1
  52. package/dist/telegram-flow.d.ts +16 -2
  53. package/dist/telegram-flow.js +58 -0
  54. package/dist/telegram.js +46 -10
  55. 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 { deciderOf } from "./flows.js";
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
- return state.prepare("SELECT token,action FROM chat_flow_action WHERE part=? AND consumed IS NULL AND expires>? ORDER BY rowid").all(part, now.toISOString())
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 false;
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).
@@ -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;
@@ -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
  : []),
@@ -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. */
@@ -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";
@@ -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: flow.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: [flow.repo],
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, [flow.repo], false);
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: [flow.repo] }, options.evidenceRoot)?.receipt?.checks ?? null;
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
- onward("ok");
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
- cuts.push({ label: labels[match[1]] ?? `What ${titleIn(definition, match[2])} found`, text: text.slice(0, 50_000) });
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
- // Back to a build zone with a finished result: a revision of that same work, carrying the note.
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 { FlowDefinition } from "./flows.js";
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;