@anchrd/intel-api 0.3.1 → 0.3.2

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.
@@ -2,6 +2,72 @@ import { IntelError } from "../shared/intel-error/intel-error.js";
2
2
  function invalid(detail) {
3
3
  throw new IntelError(400, "flow_graph_invalid", detail);
4
4
  }
5
+ // A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
6
+ // walk and the run trail, both of which follow data that other writers can change.
7
+ const MaxCallDepth = 20;
8
+ /**
9
+ * One place reads a graph for each kind of step it contains, and everything else is derived from
10
+ * these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
11
+ * list and the repository's cycle walk all ask about the same nodes; a second `kind === "…"` walk
12
+ * beside them would drift quietly, because both would keep returning something plausible.
13
+ */
14
+ export function subflowNodes(graph) {
15
+ return graph.nodes.filter((node) => node.kind === "subflow");
16
+ }
17
+ export function knowledgeNodes(graph) {
18
+ return graph.nodes.filter((node) => node.kind === "knowledge");
19
+ }
20
+ export function toolNodes(graph) {
21
+ return graph.nodes.filter((node) => node.kind === "tool");
22
+ }
23
+ // The flows a graph calls, in the order the nodes name them and without repetition. `subflowNodes`
24
+ // answers "which calls", this answers "which flows" — a flow called twice with two different version
25
+ // choices is two calls and one callee, and the freeze has to see both.
26
+ export function calleeIds(graph) {
27
+ const ids = [];
28
+ for (const node of subflowNodes(graph)) {
29
+ if (!ids.includes(node.configuration.flowId))
30
+ ids.push(node.configuration.flowId);
31
+ }
32
+ return ids;
33
+ }
34
+ /**
35
+ * The Knowledge documents a graph names and the tools it calls, flattened and without repetition.
36
+ * The requirements list, the publish-time check and the run's first tool check read this one answer,
37
+ * so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
38
+ * a document reads the node lists above instead — the relation graph draws exactly that edge.
39
+ */
40
+ export function graphReferences(graph) {
41
+ const knowledge = [];
42
+ const tools = [];
43
+ for (const node of knowledgeNodes(graph)) {
44
+ for (const resourceId of node.configuration.resourceIds) {
45
+ if (!knowledge.includes(resourceId))
46
+ knowledge.push(resourceId);
47
+ }
48
+ }
49
+ for (const node of toolNodes(graph)) {
50
+ if (!tools.includes(node.configuration.toolName))
51
+ tools.push(node.configuration.toolName);
52
+ }
53
+ return { knowledge, tools };
54
+ }
55
+ // ⚠️ The reason, in the words the person can act on, and not one word more. How many documents a
56
+ // step cannot reach is something they may know; which ones they are is the very thing the ACL is
57
+ // keeping from them, so the step is named and the documents are only counted (#17, #19).
58
+ function knowledgeStepDetail(label, missing) {
59
+ return missing === 1
60
+ ? `One document this step needs is not readable for you: ${label}`
61
+ : `${missing} documents this step needs are not readable for you: ${label}`;
62
+ }
63
+ // ⚠️ For tools this is the only honest moment there is. The catalog is a live tools/list with the
64
+ // requesting user's own token (ADR-0003), so nobody can be told in advance what someone else would
65
+ // see — but the person in front of the failure can be told exactly where to go.
66
+ function toolStepDetail(missing) {
67
+ return missing.length === 1
68
+ ? `You do not have access to this tool in the portal: ${missing.join(", ")}`
69
+ : `You do not have access to these tools in the portal: ${missing.join(", ")}`;
70
+ }
5
71
  export function compileFlow(graph) {
6
72
  const nodes = new Map();
7
73
  for (const node of graph.nodes) {
@@ -9,15 +75,31 @@ export function compileFlow(graph) {
9
75
  invalid(`Duplicate node ID: ${node.id}`);
10
76
  nodes.set(node.id, node);
11
77
  }
78
+ // ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
79
+ // step", not "this runs after it" — counted in, it would give its target a second incoming edge
80
+ // and its source a second branch, and every graph carrying context would be rejected (#37).
12
81
  const edgeIds = new Set();
13
82
  const incoming = new Map();
14
83
  const outgoing = new Map();
84
+ const attachments = new Map();
85
+ const attachedTo = new Map();
15
86
  for (const edge of graph.edges) {
16
87
  if (edgeIds.has(edge.id))
17
88
  invalid(`Duplicate edge ID: ${edge.id}`);
18
89
  edgeIds.add(edge.id);
19
90
  if (!nodes.has(edge.source) || !nodes.has(edge.target))
20
91
  invalid(`Dangling edge: ${edge.id}`);
92
+ if (edge.kind === "context") {
93
+ if (edge.source === edge.target)
94
+ invalid(`Context edge ${edge.id} cannot attach a node to itself`);
95
+ // One holder per attached node: two steps claiming the same material would make "what does
96
+ // this step work with" answerable two ways, and the second answer would never be shown.
97
+ if (attachedTo.has(edge.target))
98
+ invalid(`Node ${edge.target} is already attached to ${attachedTo.get(edge.target)}`);
99
+ attachedTo.set(edge.target, edge.source);
100
+ attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
101
+ continue;
102
+ }
21
103
  incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1);
22
104
  outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
23
105
  }
@@ -29,9 +111,22 @@ export function compileFlow(graph) {
29
111
  const trigger = triggers[0];
30
112
  if (!trigger)
31
113
  invalid("A flow requires exactly one trigger");
114
+ // The start is where the flow begins, not material a step works with. Attaching it would take it
115
+ // out of the run of steps below and leave the graph without a beginning.
116
+ if (attachedTo.has(trigger.id))
117
+ invalid("The trigger cannot be attached as context");
32
118
  for (const node of graph.nodes) {
33
119
  const parents = incoming.get(node.id) ?? 0;
34
120
  const children = outgoing.get(node.id) ?? [];
121
+ // An attached node is material, not a step: it is not in the order of work, so the rules about
122
+ // what comes before and after it do not apply. It must not be in that order either — a node
123
+ // that is both would be two things at once, and the run would have to pick one.
124
+ if (attachedTo.has(node.id)) {
125
+ if (parents !== 0 || children.length !== 0) {
126
+ invalid(`Node ${node.id} is attached as context and cannot also be a step`);
127
+ }
128
+ continue;
129
+ }
35
130
  if (node.kind === "trigger" && parents !== 0)
36
131
  invalid("The trigger cannot have an incoming edge");
37
132
  if (node.kind !== "trigger" && parents !== 1) {
@@ -62,8 +157,18 @@ export function compileFlow(graph) {
62
157
  if (visited.has(nodeId))
63
158
  return;
64
159
  active.add(nodeId);
160
+ // Only flow edges are walked: the cycle rule is about the order of work, and context is not in
161
+ // it. Attachments are marked visited so reachability still accounts for them, but stepping
162
+ // through one could never come back around — an attached node carries no flow edges at all.
163
+ //
164
+ // ⚠️ Context is one level deep, on purpose. Marking an attachment visited without recursing
165
+ // means material hung off material is unreachable and the graph is refused. A chain of "what
166
+ // this works with, works with…" is a structure nobody asked for, and allowing it would make
167
+ // "what does this step work with" a traversal instead of a lookup.
65
168
  for (const edge of outgoing.get(nodeId) ?? [])
66
169
  visit(edge.target);
170
+ for (const edge of attachments.get(nodeId) ?? [])
171
+ visited.add(edge.target);
67
172
  active.delete(nodeId);
68
173
  visited.add(nodeId);
69
174
  }
@@ -72,9 +177,69 @@ export function compileFlow(graph) {
72
177
  invalid("Every node must be reachable from the trigger");
73
178
  return { graph, triggerId: trigger.id };
74
179
  }
180
+ // What a step says when it failed and left no reason behind, and what a call says when the reason
181
+ // belongs to a run the asker may not see. Both are written once and read from two places — the
182
+ // moment a run records, and the moment somebody reads it back — so a list cannot invent a third
183
+ // wording for the same event.
184
+ const StepFailedDetail = "Flow step failed";
185
+ function calledStepDetail(label) {
186
+ return `The called flow did not finish: ${label}`;
187
+ }
75
188
  function nodeFor(version, nodeId) {
76
189
  return nodeId ? (version.graph.nodes.find((node) => node.id === nodeId) ?? null) : null;
77
190
  }
191
+ // Why a run started, read off the graph it ran rather than off a column. A called run says so
192
+ // through `parentRunId`; everything else was started by hand, because `manual` is the only mode a
193
+ // trigger node still has (#39).
194
+ //
195
+ // ⚠️ Still read from the graph rather than shortened to a constant. The graph is where the answer
196
+ // belongs, and if a second way of starting a flow is ever added it is added there — a hard-coded
197
+ // `manual` here would be a lie the day that happens, and nothing would point at this line.
198
+ function triggerOf(version, run) {
199
+ if (run.parentRunId)
200
+ return "subflow";
201
+ const trigger = version.graph.nodes.find((node) => node.kind === "trigger");
202
+ // `compileFlow` insists on exactly one trigger before a version can be saved, so the fallback is
203
+ // unreachable for anything that ever ran — it exists because the type cannot say that.
204
+ return trigger?.configuration.mode ?? "manual";
205
+ }
206
+ // Wall-clock duration of a finished run. A run still going has no answer yet, and inventing "so far"
207
+ // here would be a number that is stale the moment it is read.
208
+ function durationOf(run) {
209
+ if (!run.completedAt)
210
+ return null;
211
+ return Math.max(0, Date.parse(run.completedAt) - Date.parse(run.createdAt));
212
+ }
213
+ // A call site is two identifiers, and a Map wants one key. Written once so the lookup and the fill
214
+ // cannot disagree about how the pair is spelled; `\u0000` cannot occur in either half.
215
+ function callKey(runId, nodeId) {
216
+ return `${runId}\u0000${nodeId}`;
217
+ }
218
+ // What a call step may say to this asker. A called run they may see hands its own text over whole;
219
+ // otherwise the caller's step names itself and nothing else crosses the boundary. A step that
220
+ // recorded no reason keeps none — that is a completed call, not a silent failure.
221
+ function callDetail(calledRunId, label, stored) {
222
+ if (calledRunId)
223
+ return stored;
224
+ return stored === null ? null : calledStepDetail(label);
225
+ }
226
+ // The page marker of the run list. `created_at` alone is not unique — two runs of the same flow can
227
+ // share a millisecond — so the row's ID rides along and the keyset stays exact.
228
+ function encodeCursor(run) {
229
+ return `${run.createdAt}|${run.id}`;
230
+ }
231
+ function decodeCursor(cursor) {
232
+ if (!cursor)
233
+ return null;
234
+ const separator = cursor.indexOf("|");
235
+ if (separator <= 0)
236
+ throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
237
+ const createdAt = cursor.slice(0, separator);
238
+ const id = cursor.slice(separator + 1);
239
+ if (!id)
240
+ throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
241
+ return { createdAt, id };
242
+ }
78
243
  export function createFlows(deps) {
79
244
  async function requireFlow(actor, flowId) {
80
245
  const flow = await deps.repository.getVisible(actor, flowId);
@@ -82,6 +247,16 @@ export function createFlows(deps) {
82
247
  throw new IntelError(404, "flow_not_found", "Flow was not found");
83
248
  return flow;
84
249
  }
250
+ // ⚠️ On the execution path a flow is resolved by `read` or `execute`, never by `read` alone. A
251
+ // library folder carries `execute` for everyone and nothing else (ADR-0004 §2/§3), so insisting on
252
+ // `read` here would turn every library flow into a 404 for exactly the people it exists for — and
253
+ // the call rule in section 3 would have nothing left to permit.
254
+ async function requireRunnableFlow(actor, flowId) {
255
+ const flow = await deps.repository.getCallable(actor, flowId);
256
+ if (!flow || flow.archivedAt)
257
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
258
+ return flow;
259
+ }
85
260
  // Every surface maps the Gate capability into the actor, so the rule lives here once instead of
86
261
  // being restated per surface. Checked before any storage access.
87
262
  function requireRun(actor) {
@@ -91,17 +266,18 @@ export function createFlows(deps) {
91
266
  }
92
267
  async function requireEdit(actor, flowId) {
93
268
  const flow = await requireFlow(actor, flowId);
94
- if (!(await deps.repository.canEdit(actor, flowId))) {
269
+ if (!(await deps.repository.can(actor, flowId, "write"))) {
95
270
  throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
96
271
  }
97
272
  return flow;
98
273
  }
99
- async function requireManage(actor, flowId) {
100
- const flow = await requireFlow(actor, flowId);
101
- if (!(await deps.repository.canManage(actor, flowId))) {
102
- throw new IntelError(403, "flow_manage_forbidden", "Flow sharing cannot be managed");
274
+ // Seeing a flow and starting it are two grants, not one (ADR-0004 §2): /crm is readable for sales
275
+ // and executable only for billing. This is asked before anything a run would touch — no
276
+ // idempotency lookup, no portal call, no run row — so a refusal leaves no trace behind.
277
+ async function requireExecute(actor, flowId) {
278
+ if (!(await deps.repository.can(actor, flowId, "execute"))) {
279
+ throw new IntelError(403, "flow_execute_forbidden", "Flow execute permission is required");
103
280
  }
104
- return flow;
105
281
  }
106
282
  // A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
107
283
  // second permission model here. `null` is the root and needs no permission of its own — the same
@@ -127,10 +303,291 @@ export function createFlows(deps) {
127
303
  }
128
304
  return version;
129
305
  }
306
+ // Which step of which flow is running, outermost caller first. Built from the stored chain rather
307
+ // than from the run at hand, so a caller looking at the outer run sees the step inside the called
308
+ // flow, and a caller looking at the called run sees who called it.
309
+ async function trailFor(run, known) {
310
+ const chain = await deps.repository.runChain(run.id);
311
+ const byId = new Map(chain.map((entry) => [entry.runId, entry]));
312
+ const child = new Map();
313
+ for (const entry of chain) {
314
+ if (entry.parentRunId && byId.has(entry.parentRunId))
315
+ child.set(entry.parentRunId, entry.runId);
316
+ }
317
+ let head = chain.find((entry) => !entry.parentRunId || !byId.has(entry.parentRunId));
318
+ const ordered = [];
319
+ while (head && ordered.length < MaxCallDepth) {
320
+ ordered.push(head);
321
+ const next = child.get(head.runId);
322
+ head = next ? byId.get(next) : undefined;
323
+ }
324
+ // ⚠️ The versions in one read, after the walk rather than inside it (#30). This function sits on
325
+ // the path every step of every run takes, so a query per level was a cost paid per step and
326
+ // growing with the depth of the call chain — invisible at two levels, linear at twenty. The
327
+ // version already in hand is not asked for again, and a label whose version is missing stays
328
+ // `null` exactly as it did.
329
+ const versions = new Map([[known.id, known]]);
330
+ const missing = [...new Set(ordered.map((entry) => entry.versionId))].filter((versionId) => !versions.has(versionId));
331
+ for (const version of await deps.repository.getVersions(missing)) {
332
+ versions.set(version.id, version);
333
+ }
334
+ return ordered.map((entry) => {
335
+ const version = versions.get(entry.versionId);
336
+ return {
337
+ runId: entry.runId,
338
+ flowId: entry.flowId,
339
+ flowTitle: entry.flowTitle,
340
+ nodeId: entry.currentNodeId,
341
+ nodeLabel: version ? (nodeFor(version, entry.currentNodeId)?.label ?? null) : null,
342
+ };
343
+ });
344
+ }
345
+ // The documents of a list this actor may reach, named. Distinct IDs only, so a reference written
346
+ // twice is one document; an ID that is missing from the answer is one they cannot reach.
347
+ //
348
+ // ⚠️ It is `deps.visibleKnowledge` and nothing else, which is what keeps the authorization check,
349
+ // the requirements list and the relation graph on one rule. Only `id` and `title` travel onwards:
350
+ // the node also carries `parentId`, `ownerId` and its version, and where a document sits in the
351
+ // tree is not part of the question "what does this flow need" (#17, #19).
352
+ async function reachableKnowledge(actor, resourceIds) {
353
+ const reachable = [];
354
+ for (const resourceId of new Set(resourceIds)) {
355
+ const node = await deps.visibleKnowledge(actor, resourceId);
356
+ if (node)
357
+ reachable.push({ id: node.id, title: node.title });
358
+ }
359
+ return reachable;
360
+ }
361
+ // ⚠️ ADR-0004 §4, and the reason a subflow is not a way around anything. Before a Knowledge or
362
+ // Tool step is handed to whoever will carry it out — first run, retry and resume alike — the
363
+ // current Gate identity and the resource ACL are asked again. A called flow's steps come through
364
+ // this same function, so a document the user may not read stays unreadable however deep the call
365
+ // sits, and a flow grant keeps protecting the procedure rather than the data.
366
+ async function requireNodeAuthorized(actor, node) {
367
+ if (node?.kind === "knowledge") {
368
+ const wanted = [...new Set(node.configuration.resourceIds)];
369
+ const reachable = await reachableKnowledge(actor, wanted);
370
+ if (reachable.length !== wanted.length) {
371
+ throw new IntelError(403, "flow_knowledge_forbidden", knowledgeStepDetail(node.label, wanted.length - reachable.length));
372
+ }
373
+ }
374
+ if (node?.kind === "tool") {
375
+ const missing = await deps.unavailableTools(actor, [node.configuration.toolName]);
376
+ if (missing.length) {
377
+ throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
378
+ }
379
+ }
380
+ }
381
+ /**
382
+ * ⚠️ The boundary this whole slice turns on. A sub-flow step's reason for failing was written
383
+ * inside the *called* run, and that run is a run of its own with its own authorization — a library
384
+ * flow carries `execute` for everyone and `read` for nobody (ADR-0004 §3), so somebody who may
385
+ * read the calling flow can easily have no claim at all on what happened inside the called one.
386
+ *
387
+ * So the call sites are asked through the very predicate that opens a single run, and never
388
+ * through a second rule written for display. A site that comes back is one whose called run this
389
+ * actor may see: the stored text then travels and the called run is named so the reader can
390
+ * follow it. A site that stays out is reported by the *caller's* own label — the caller's to give
391
+ * — with a text that says no more than that the call did not finish. Named what they may see,
392
+ * counted the rest (#17, #19, #20).
393
+ *
394
+ * A whole page of sites in one read (#30): a list that asked per row would put two queries on
395
+ * every failed line, which is exactly the shape that ticket removed everywhere else.
396
+ */
397
+ async function visibleCalls(actor, sites) {
398
+ if (sites.length === 0)
399
+ return new Map();
400
+ const found = await deps.repository.visibleCallRuns(actor, sites);
401
+ return new Map(found.map((call) => [callKey(call.runId, call.nodeId), call.calledRunId]));
402
+ }
130
403
  async function step(actor, run) {
131
- await requireFlow(actor, run.flowId);
404
+ await requireRunnableFlow(actor, run.flowId);
132
405
  const version = await requireVersion(run.versionId, run.flowId);
133
- return { run, node: nodeFor(version, run.currentNodeId) };
406
+ const node = nodeFor(version, run.currentNodeId);
407
+ await requireNodeAuthorized(actor, node);
408
+ return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
409
+ }
410
+ // A failed run carries the text of the step that failed, and for a call that text came out of
411
+ // another run. It is narrowed here rather than at the door it entered through: what storage keeps
412
+ // is what happened, and who may read it is a question about the person asking, not about the row.
413
+ //
414
+ // Only a failed run pays for this, and then two reads at most — a run that is still going leaves
415
+ // before the first one.
416
+ async function narrowed(actor, run, version) {
417
+ if (run.status !== "failed" || !run.error)
418
+ return run;
419
+ const failed = (await deps.repository.failedSteps([run.id]))[0];
420
+ const node = failed ? nodeFor(version, failed.nodeId) : null;
421
+ if (!failed || node?.kind !== "subflow")
422
+ return run;
423
+ const calls = await visibleCalls(actor, [{ runId: run.id, nodeId: failed.nodeId }]);
424
+ const detail = callDetail(calls.get(callKey(run.id, failed.nodeId)), node.label, run.error);
425
+ return detail === run.error ? run : { ...run, error: detail };
426
+ }
427
+ // ADR-0004 §3, checked where it can still be answered honestly: at publish time, naming the reason
428
+ // rather than the error. A call is allowed into the caller's own folder or below it, or into a
429
+ // folder whose `execute` reaches at least as far as the caller's own — in practice `execute` for
430
+ // everyone, the library.
431
+ async function requireCallRule(actor, flow, graph) {
432
+ for (const calleeId of calleeIds(graph)) {
433
+ const callee = await deps.repository.getCallable(actor, calleeId);
434
+ if (!callee || callee.archivedAt) {
435
+ throw new IntelError(409, "flow_subflow_unavailable", `Called flow is unavailable: ${calleeId}`);
436
+ }
437
+ if (callee.id === flow.id)
438
+ continue;
439
+ if ((await deps.repository.callReach(flow.parentId, callee.parentId)) === "out-of-reach") {
440
+ throw new IntelError(409, "flow_subflow_out_of_reach", `${flow.title} may not call ${callee.title}: it is neither in ${flow.title}'s folder nor below it, and its folder is not executable for everyone. Move it into the calling flow's subtree, or share its folder's execute with the organization.`);
441
+ }
442
+ }
443
+ }
444
+ // Calls point downwards, so a cycle cannot form inside one subtree. It can come back over the
445
+ // library edge, and that is what this walks — once, at publish time, naming the whole chain
446
+ // instead of the one edge that closed it.
447
+ async function requireNoCallCycle(flow, graph) {
448
+ const titles = new Map([[flow.id, flow.title]]);
449
+ const settled = new Set();
450
+ const named = (ids) => ids.map((id) => titles.get(id) ?? id).join(" → ");
451
+ async function walk(callees, path) {
452
+ for (const calleeId of callees) {
453
+ const closes = path.indexOf(calleeId);
454
+ if (closes !== -1) {
455
+ throw new IntelError(409, "flow_subflow_cycle", `Publishing would close a call cycle: ${named([...path.slice(closes), calleeId])}`);
456
+ }
457
+ if (settled.has(calleeId))
458
+ continue;
459
+ const next = await deps.repository.publishedCallees(calleeId);
460
+ settled.add(calleeId);
461
+ if (!next)
462
+ continue;
463
+ titles.set(calleeId, next.title);
464
+ if (path.length >= MaxCallDepth) {
465
+ throw new IntelError(409, "flow_subflow_too_deep", `Calls are nested more than ${MaxCallDepth} deep: ${named([...path, calleeId])}`);
466
+ }
467
+ await walk(next.calleeIds, [...path, calleeId]);
468
+ }
469
+ }
470
+ await walk(calleeIds(graph), [flow.id]);
471
+ }
472
+ // What each call of a graph will take, as the publishing author may see it (ADR-0004 §5). One
473
+ // reader for the preview and the freeze alike: if the screen computed this and publishing computed
474
+ // it again, the two would drift and the author would have agreed to something else than what
475
+ // happened.
476
+ //
477
+ // ⚠️ A callee this actor cannot reach is left out rather than described. The preview is a list of
478
+ // titles, and a title is exactly what an unreachable flow must not hand out — the same mistake the
479
+ // review of #17 found in an error message.
480
+ async function calls(actor, graph) {
481
+ const seen = new Map();
482
+ const items = [];
483
+ for (const node of subflowNodes(graph)) {
484
+ const calleeId = node.configuration.flowId;
485
+ if (!seen.has(calleeId)) {
486
+ const found = await deps.repository.getCallable(actor, calleeId);
487
+ seen.set(calleeId, found && !found.archivedAt ? found : null);
488
+ }
489
+ const callee = seen.get(calleeId) ?? null;
490
+ if (!callee)
491
+ continue;
492
+ const selection = node.configuration.version;
493
+ const pinned = selection.mode === "pinned" ? selection.versionId : (callee.publishedVersionId ?? null);
494
+ // ⚠️ Resolved *against the callee*, never by identifier alone. `getVersion` returns whatever
495
+ // version carries that ID, whichever flow it belongs to — so a pin naming a foreign version
496
+ // would have its sequence reported here, a fact about a flow the reader was never shown, and
497
+ // the very same pin would then be frozen into the published graph. This is the check the
498
+ // freeze makes, made in the same words, so the two cannot drift apart.
499
+ const version = pinned ? await requireVersion(pinned, calleeId) : null;
500
+ items.push({
501
+ nodeId: node.id,
502
+ nodeLabel: node.label,
503
+ calleeId,
504
+ calleeTitle: callee.title,
505
+ mode: selection.mode,
506
+ // A call that follows has no answer until it runs, and saying "version 4" here would be a
507
+ // promise the next publication of the callee breaks.
508
+ versionId: selection.mode === "follows" ? null : pinned,
509
+ versionSequence: selection.mode === "follows" ? null : (version?.sequence ?? null),
510
+ freezes: selection.mode === "latest",
511
+ available: Boolean(callee.publishedVersionId),
512
+ });
513
+ }
514
+ return items;
515
+ }
516
+ // ⚠️ ADR-0004 §5, and the reason a published flow keeps doing what it did. `latest` is replaced by
517
+ // the callee's published version here and nowhere else; without it, changing a building block
518
+ // would silently change every flow that uses it. `follows` is left exactly as it is — it was
519
+ // chosen on purpose, and overwriting it would take that choice away again.
520
+ //
521
+ // Versions are immutable, so this cannot rewrite the one being published: it returns the frozen
522
+ // graph and the caller appends it as a new version.
523
+ async function freeze(actor, graph) {
524
+ let changed = false;
525
+ const nodes = [];
526
+ for (const node of graph.nodes) {
527
+ if (node.kind !== "subflow") {
528
+ nodes.push(node);
529
+ continue;
530
+ }
531
+ const selection = node.configuration.version;
532
+ if (selection.mode === "pinned") {
533
+ // A pin is only worth anything while the version behind it is still there. Publishing a
534
+ // dangling pin would produce a flow that fails at its first call instead of at publish time.
535
+ await requireVersion(selection.versionId, node.configuration.flowId);
536
+ nodes.push(node);
537
+ continue;
538
+ }
539
+ // A call that rides along is left untouched, unpublished callee included: it resolves at run
540
+ // time by definition, and refusing here would forbid building a caller before its building
541
+ // block is finished — which is not what choosing "always latest" asked for.
542
+ if (selection.mode === "follows") {
543
+ nodes.push(node);
544
+ continue;
545
+ }
546
+ const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
547
+ if (!callee?.publishedVersionId) {
548
+ // There is nothing to freeze to. Refused here, where the reason is readable, rather than at
549
+ // the first run of the call, where it would arrive as someone else's flow failing.
550
+ throw new IntelError(409, "flow_subflow_not_published", `The called flow has nothing published to freeze to: ${node.label}`);
551
+ }
552
+ changed = true;
553
+ nodes.push({
554
+ ...node,
555
+ configuration: {
556
+ ...node.configuration,
557
+ version: { mode: "pinned", versionId: callee.publishedVersionId },
558
+ },
559
+ });
560
+ }
561
+ return changed ? { ...graph, nodes } : null;
562
+ }
563
+ // The place a call is made from. It names a step, never a permission: the callee's `execute` was
564
+ // already asked of the user, and the caller's is asked again here, so a grant revoked while the
565
+ // outer run waited cannot be walked around by starting the inner one.
566
+ async function requireCallSite(actor, parent, calleeId) {
567
+ const run = await deps.repository.getRunVisible(actor, parent.runId);
568
+ if (!run)
569
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
570
+ if (run.initiatedBy !== actor.id) {
571
+ throw new IntelError(403, "flow_call_site_forbidden", "The calling run belongs to someone else");
572
+ }
573
+ await requireExecute(actor, run.flowId);
574
+ if (run.status !== "running" && run.status !== "waiting") {
575
+ throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
576
+ }
577
+ if (run.currentNodeId !== parent.nodeId) {
578
+ throw new IntelError(409, "flow_step_conflict", "A different step is currently active");
579
+ }
580
+ const version = await requireVersion(run.versionId, run.flowId);
581
+ const node = nodeFor(version, parent.nodeId);
582
+ if (node?.kind !== "subflow" || node.configuration.flowId !== calleeId) {
583
+ throw new IntelError(409, "flow_call_site_invalid", "That step does not call this flow");
584
+ }
585
+ // ⚠️ The version the call takes comes from the calling graph, which is immutable, and never from
586
+ // the request. A frozen call runs what it was frozen to however often the callee is published
587
+ // afterwards; a call that follows resolves to whatever is published at this moment, and the run
588
+ // row records which of the two it turned out to be (ADR-0004 §5).
589
+ const selection = node.configuration.version;
590
+ return { run, versionId: selection.mode === "pinned" ? selection.versionId : null };
134
591
  }
135
592
  return {
136
593
  async list(actor, input = {}) {
@@ -145,6 +602,213 @@ export function createFlows(deps) {
145
602
  : null,
146
603
  };
147
604
  },
605
+ // What accesses what, for one level of the shared tree (#19). Flows answers it because the edges
606
+ // live in flow graphs; Knowledge answers which nodes exist and who may see them, because the
607
+ // tree and its ACLs are Knowledge's (ADR-0004 §1).
608
+ //
609
+ // ⚠️ Every node passes an authorization before it is drawn, and a node that fails it is left out
610
+ // entirely — no placeholder, no count, no edge. An edge to a grey box would already say that
611
+ // something is there and that this flow touches it, which is the whole of what was meant to stay
612
+ // hidden. `omitted` counts what the size limit cut, and nothing else.
613
+ async relationGraph(actor, input) {
614
+ const nodes = new Map();
615
+ const edges = [];
616
+ const dropped = new Set();
617
+ const knowledgeSeen = new Map();
618
+ const flowSeen = new Map();
619
+ // The level being drawn, what was actually read of it, and how much of it the bound left
620
+ // behind. `undefined` is the single-flow scope, which has no level and reads no list.
621
+ const levelFolderId = input.scope.of === "folder" ? input.scope.folderId : undefined;
622
+ const levelIds = new Set();
623
+ let unread = 0;
624
+ function place(node, parentId) {
625
+ const existing = nodes.get(node.id);
626
+ if (existing) {
627
+ // A target that also lives in the level keeps the stronger of the two answers.
628
+ if (node.inScope && !existing.inScope)
629
+ nodes.set(node.id, node);
630
+ return true;
631
+ }
632
+ if (nodes.size >= input.limit) {
633
+ // ⚠️ A node filed in this level that the bound never read is already in `unread`. Counting
634
+ // it a second time because a drawn flow happens to reach it would make the size the
635
+ // picture reports differ from the number of nodes it actually left out.
636
+ if (parentId !== levelFolderId || levelIds.has(node.id))
637
+ dropped.add(node.id);
638
+ return false;
639
+ }
640
+ nodes.set(node.id, node);
641
+ return true;
642
+ }
643
+ // ⚠️ Archived is decided here rather than behind the port, beside the identical rule
644
+ // `calleeFlow` applies one function down. The port answers one question — may this actor see
645
+ // it — because the run path asks it too, and a document being archived must not start
646
+ // refusing steps under a message that names the wrong reason. What a drawing leaves out and
647
+ // what a run refuses are two decisions; only the first belongs to #19.
648
+ async function knowledgeNode(nodeId) {
649
+ if (!knowledgeSeen.has(nodeId)) {
650
+ const found = await deps.visibleKnowledge(actor, nodeId);
651
+ knowledgeSeen.set(nodeId, found && !found.archivedAt
652
+ ? {
653
+ node: { id: found.id, kind: found.kind, title: found.title, inScope: false },
654
+ parentId: found.parentId,
655
+ }
656
+ : null);
657
+ }
658
+ return knowledgeSeen.get(nodeId) ?? null;
659
+ }
660
+ // The rows are already in hand — every callee of the level was read in one statement below —
661
+ // so this is the archived rule and nothing else.
662
+ function calleeFlow(flowId) {
663
+ const found = flowSeen.get(flowId);
664
+ return found && !found.archivedAt ? found : null;
665
+ }
666
+ // The level itself first, so a big folder spends its budget on its own contents rather than on
667
+ // whatever the first flow in it happens to reach.
668
+ //
669
+ // ⚠️ `limit` bounds what is *read* as well as what is drawn (#30). Both reads stop at it, and
670
+ // what they stopped short of comes back as a count instead of as rows. The order inside those
671
+ // statements is the part that must not move: the visibility predicate first and the bound
672
+ // after it, so the cut falls among the rows this actor may see — a bound applied first would
673
+ // let a row they may never see take up a place in the picture, and the number below would then
674
+ // be saying that it is there.
675
+ let scopeFlows;
676
+ if (input.scope.of === "flow") {
677
+ scopeFlows = [await requireFlow(actor, input.scope.flowId)];
678
+ }
679
+ else {
680
+ const children = await deps.knowledgeChildren(actor, input.scope.folderId, input.limit);
681
+ unread += children.total - children.items.length;
682
+ for (const item of children.items) {
683
+ levelIds.add(item.id);
684
+ place({ id: item.id, kind: item.kind, title: item.title, inScope: true }, item.parentId);
685
+ }
686
+ const filed = await deps.repository.listVisibleBounded(actor, input.scope.folderId, input.limit);
687
+ unread += filed.total - filed.items.length;
688
+ for (const flow of filed.items)
689
+ levelIds.add(flow.id);
690
+ scopeFlows = filed.items;
691
+ }
692
+ const walkable = scopeFlows.filter((flow) => place({ id: flow.id, kind: "flow", title: flow.title, inScope: true }, flow.parentId));
693
+ // The draft is what the author is looking at; the published graph is what a flow without one
694
+ // still does. Reading neither would leave a flow in the picture with no edges at all.
695
+ const versions = new Map((await deps.repository.getVersions([
696
+ ...new Set(walkable
697
+ .map((flow) => flow.currentVersionId ?? flow.publishedVersionId)
698
+ .filter((versionId) => versionId !== null)),
699
+ ])).map((version) => [version.id, version]));
700
+ const drawable = walkable.flatMap((flow) => {
701
+ const version = versions.get(flow.currentVersionId ?? flow.publishedVersionId ?? "");
702
+ return version ? [{ flow, version }] : [];
703
+ });
704
+ // One read for the versions of the level and one for everything it calls, instead of a pair
705
+ // per flow (#30). Both are bounded by the level, which is bounded by `limit`. Nothing below
706
+ // changes because of it: the walk is the same walk in the same order, over rows already in
707
+ // hand, so what is drawn and what is cut are what they were.
708
+ for (const callee of await deps.repository.listCallable(actor, [
709
+ ...new Set(drawable.flatMap(({ version }) => subflowNodes(version.graph).map((node) => node.configuration.flowId))),
710
+ ])) {
711
+ flowSeen.set(callee.id, callee);
712
+ }
713
+ for (const { flow, version } of drawable) {
714
+ // The node lists rather than a walk of `graph.nodes` with two `kind` tests: an edge has to
715
+ // name the step it comes from, so this is the one caller that needs the nodes themselves
716
+ // and not the flattened `graphReferences`.
717
+ for (const node of knowledgeNodes(version.graph)) {
718
+ for (const resourceId of node.configuration.resourceIds) {
719
+ const target = await knowledgeNode(resourceId);
720
+ if (!target || !place(target.node, target.parentId))
721
+ continue;
722
+ edges.push({
723
+ id: `reads:${flow.id}:${node.id}:${resourceId}`,
724
+ source: flow.id,
725
+ target: resourceId,
726
+ relation: "reads",
727
+ });
728
+ }
729
+ }
730
+ for (const node of subflowNodes(version.graph)) {
731
+ const callee = calleeFlow(node.configuration.flowId);
732
+ if (!callee ||
733
+ !place({ id: callee.id, kind: "flow", title: callee.title, inScope: false }, callee.parentId)) {
734
+ continue;
735
+ }
736
+ edges.push({
737
+ id: `calls:${flow.id}:${node.id}:${callee.id}`,
738
+ source: flow.id,
739
+ target: callee.id,
740
+ relation: "calls",
741
+ });
742
+ }
743
+ }
744
+ return {
745
+ scope: input.scope,
746
+ nodes: [...nodes.values()],
747
+ edges,
748
+ // What did not fit and what was never read for lack of room: two ways of being left out,
749
+ // one number, and neither of them a permission (#19).
750
+ omitted: dropped.size + unread,
751
+ limit: input.limit,
752
+ };
753
+ },
754
+ // Read out of the graph, never out of `parent_id`: what a flow calls is what it does, and where
755
+ // it is filed is only where it is filed. A flow used by three callers is listed under all three.
756
+ async listCalls(actor, flowId) {
757
+ const flow = await requireFlow(actor, flowId);
758
+ const versionId = flow.currentVersionId ?? flow.publishedVersionId;
759
+ if (!versionId)
760
+ return { items: [] };
761
+ const version = await requireVersion(versionId, flow.id);
762
+ const wanted = calleeIds(version.graph);
763
+ // One read for every callee rather than one per callee (#30). This is what the sidebar asks
764
+ // when a flow is unfolded, so it runs while somebody is only looking around.
765
+ //
766
+ // ⚠️ The order stays the graph's, restored here from the storage layer's. A call list answers
767
+ // "what do I run, and how", and that is the order the steps stand in; a callee this actor may
768
+ // neither open nor run is absent from the answer, exactly as it was.
769
+ const callable = new Map((await deps.repository.listCallable(actor, wanted)).map((callee) => [callee.id, callee]));
770
+ const items = [];
771
+ for (const calleeId of wanted) {
772
+ const callee = callable.get(calleeId);
773
+ if (callee && !callee.archivedAt)
774
+ items.push(callee);
775
+ }
776
+ return { items };
777
+ },
778
+ // "What this flow needs", straight out of the graph: no arithmetic, nothing that can go stale,
779
+ // and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
780
+ // would be wrong for tools by construction — the catalog is a live query with the requesting
781
+ // user's own token (ADR-0003) — and out of date for documents most of the time.
782
+ async listRequirements(actor, flowId) {
783
+ const flow = await requireFlow(actor, flowId);
784
+ // The draft is what the editor is looking at; a flow with only a published version has
785
+ // nothing else to describe.
786
+ const versionId = flow.currentVersionId ?? flow.publishedVersionId;
787
+ if (!versionId) {
788
+ return {
789
+ flowId: flow.id,
790
+ versionId: null,
791
+ knowledge: [],
792
+ hiddenKnowledge: 0,
793
+ tools: [],
794
+ };
795
+ }
796
+ const version = await requireVersion(versionId, flow.id);
797
+ const referenced = graphReferences(version.graph);
798
+ // ⚠️ The same lookup a Knowledge step passes through, not a second rule written for a list.
799
+ // What it hands back is named; the difference between what was asked for and what came back
800
+ // is a number, because a title is exactly what someone without access may not learn (#17,
801
+ // #19). Tool names are not filtered: they come from a graph this actor may already read, and
802
+ // whether the portal offers them is a question only their own token can answer.
803
+ const knowledge = await reachableKnowledge(actor, referenced.knowledge);
804
+ return {
805
+ flowId: flow.id,
806
+ versionId: version.id,
807
+ knowledge,
808
+ hiddenKnowledge: referenced.knowledge.length - knowledge.length,
809
+ tools: referenced.tools,
810
+ };
811
+ },
148
812
  async create(actor, input) {
149
813
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
150
814
  if (replayed)
@@ -232,6 +896,18 @@ export function createFlows(deps) {
232
896
  }
233
897
  return { flow: await requireFlow(actor, flow.id), version };
234
898
  },
899
+ // What publishing will do to the calls, before it does it (ADR-0004 §5). It reads and writes
900
+ // nothing, and it asks for `write` rather than `read`: only someone who could publish has any
901
+ // business reading which versions a publication would pin.
902
+ async previewPublish(actor, input) {
903
+ const flow = await requireEdit(actor, input.flowId);
904
+ const version = await requireVersion(input.versionId, flow.id);
905
+ return {
906
+ flowId: flow.id,
907
+ versionId: version.id,
908
+ calls: await calls(actor, version.graph),
909
+ };
910
+ },
235
911
  async publish(actor, input) {
236
912
  const flow = await requireEdit(actor, input.flowId);
237
913
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.publish", input.idempotencyKey);
@@ -241,8 +917,9 @@ export function createFlows(deps) {
241
917
  compileFlow(version.graph);
242
918
  for (const node of version.graph.nodes) {
243
919
  if (node.kind === "knowledge") {
920
+ const reachable = new Set((await reachableKnowledge(actor, node.configuration.resourceIds)).map((reference) => reference.id));
244
921
  for (const resourceId of node.configuration.resourceIds) {
245
- if (!(await deps.knowledgeExists(actor, resourceId))) {
922
+ if (!reachable.has(resourceId)) {
246
923
  throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
247
924
  }
248
925
  }
@@ -257,9 +934,57 @@ export function createFlows(deps) {
257
934
  }
258
935
  }
259
936
  }
937
+ // Both refusals happen before the flow is published, so a call that breaks the rule never
938
+ // becomes something a run could follow.
939
+ await requireCallRule(actor, flow, version.graph);
940
+ await requireNoCallCycle(flow, version.graph);
941
+ // ADR-0004 §5: `latest` becomes the concrete version here. A version is immutable, so the
942
+ // freeze cannot rewrite the one being published — it appends the frozen graph as the next
943
+ // version and publishes that. The author saw the same list through `previewPublish` first.
944
+ const frozenGraph = await freeze(actor, version.graph);
945
+ let publishable = version;
946
+ if (frozenGraph) {
947
+ // ⚠️ Keyed on the version being frozen, not on the request. Freezing version X is a
948
+ // function of X, so every attempt at it has to land on the one version the first attempt
949
+ // appended: a retry whose answer was lost, and a second confirmation of the same preview,
950
+ // are re-entries rather than repetitions. With a per-request key the second attempt would
951
+ // append a second version — and could then never publish, because the draft it was based on
952
+ // is no longer the current one and the guard below would refuse it forever.
953
+ const freezeKey = `freeze:${version.id}`;
954
+ const already = await deps.repository.findIdempotent(actor.id, "flows.save", freezeKey);
955
+ if (already) {
956
+ publishable = await requireVersion(already, flow.id);
957
+ }
958
+ else {
959
+ // Appending only ever continues the draft. Freezing an older version would fork the
960
+ // chain and leave the editor holding a version that is no longer the flow's current one.
961
+ if (flow.currentVersionId !== version.id) {
962
+ throw new IntelError(409, "flow_publish_not_current", "Only the current version can be published while it still calls a latest sub-flow");
963
+ }
964
+ const frozen = {
965
+ id: deps.id(),
966
+ flowId: flow.id,
967
+ sequence: version.sequence + 1,
968
+ graph: frozenGraph,
969
+ createdBy: actor.id,
970
+ createdAt: deps.now().toISOString(),
971
+ };
972
+ const saved = await deps.repository.insertVersion({
973
+ version: frozen,
974
+ baseVersionId: version.id,
975
+ actorId: actor.id,
976
+ idempotencyKey: freezeKey,
977
+ auditId: deps.id(),
978
+ });
979
+ if (saved === "conflict") {
980
+ throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
981
+ }
982
+ publishable = frozen;
983
+ }
984
+ }
260
985
  const published = await deps.repository.publish({
261
986
  flowId: flow.id,
262
- versionId: version.id,
987
+ versionId: publishable.id,
263
988
  actorId: actor.id,
264
989
  idempotencyKey: input.idempotencyKey,
265
990
  auditId: deps.id(),
@@ -271,6 +996,20 @@ export function createFlows(deps) {
271
996
  },
272
997
  async start(actor, input) {
273
998
  requireRun(actor);
999
+ // ⚠️ The whole guarantee of a subflow sits on this line staying where it is. A called run is
1000
+ // started through the same door as any other: the user's own `execute` on the called flow is
1001
+ // asked first, and the calling flow lends nothing. Without it a subflow node would be a way
1002
+ // around every folder grant in the tree (ADR-0004 §4).
1003
+ await requireExecute(actor, input.flowId);
1004
+ const call = input.parent ? await requireCallSite(actor, input.parent, input.flowId) : null;
1005
+ const parent = call?.run ?? null;
1006
+ // One call site starts one run. A repeated call hands back the run it already made instead of
1007
+ // a second one whose result nothing would ever read.
1008
+ if (parent && input.parent) {
1009
+ const existing = await deps.repository.findChildRun(parent.id, input.parent.nodeId);
1010
+ if (existing)
1011
+ return await step(actor, existing);
1012
+ }
274
1013
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.run", input.idempotencyKey);
275
1014
  if (replayed) {
276
1015
  const run = await deps.repository.getRunVisible(actor, replayed);
@@ -280,23 +1019,31 @@ export function createFlows(deps) {
280
1019
  return await step(actor, run);
281
1020
  }
282
1021
  }
283
- const flow = await requireFlow(actor, input.flowId);
1022
+ const flow = await requireRunnableFlow(actor, input.flowId);
1023
+ // Asked even for a frozen call: a flow whose publication was withdrawn is not something a
1024
+ // pinned version may quietly keep running.
284
1025
  if (!flow.publishedVersionId) {
285
1026
  throw new IntelError(409, "flow_not_published", "Flow has no published version");
286
1027
  }
287
- const version = await requireVersion(flow.publishedVersionId, flow.id);
1028
+ // A frozen call runs its own version; everything else runs what is published now. Both land in
1029
+ // `run.versionId` below, so a run always records which version actually ran (ADR-0004 §5).
1030
+ const version = await requireVersion(call?.versionId ?? flow.publishedVersionId, flow.id);
288
1031
  const compiled = compileFlow(version.graph);
289
- const first = version.graph.edges.find((edge) => edge.source === compiled.triggerId)?.target;
1032
+ // ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
1033
+ // node, because that edge can come first in the list (#37).
1034
+ const first = version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target;
290
1035
  if (!first)
291
1036
  invalid("The trigger requires an outgoing edge");
292
1037
  // The flow may have been built by someone with wider portal access. Naming the missing tools
293
- // before the first step beats failing halfway through with a portal error the user cannot place.
294
- const missing = await deps.unavailableTools(actor, version.graph.nodes
295
- .filter((node) => node.kind === "tool")
296
- .map((node) => (node.kind === "tool" ? node.configuration.toolName : "")));
1038
+ // before the first step beats failing halfway through with a portal error the user cannot
1039
+ // place and the portal is where they can do something about it.
1040
+ const missing = await deps.unavailableTools(actor, graphReferences(version.graph).tools);
297
1041
  if (missing.length) {
298
- throw new IntelError(403, "flow_tools_unavailable", `You cannot reach these tools: ${missing.join(", ")}`);
1042
+ throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
299
1043
  }
1044
+ // The first step is authorized before a run row exists, so a refusal leaves nothing behind at
1045
+ // all — not a run, not an idempotency key, not an audit event.
1046
+ await requireNodeAuthorized(actor, nodeFor(version, first));
300
1047
  const occurredAt = deps.now().toISOString();
301
1048
  const run = {
302
1049
  id: deps.id(),
@@ -308,6 +1055,8 @@ export function createFlows(deps) {
308
1055
  output: null,
309
1056
  error: null,
310
1057
  initiatedBy: actor.id,
1058
+ parentRunId: parent?.id ?? null,
1059
+ parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
311
1060
  createdAt: occurredAt,
312
1061
  updatedAt: occurredAt,
313
1062
  completedAt: null,
@@ -327,6 +1076,137 @@ export function createFlows(deps) {
327
1076
  throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
328
1077
  return await step(actor, run);
329
1078
  },
1079
+ /**
1080
+ * What this flow has done, newest first (#35). Until now a run could only be found by an ID
1081
+ * somebody still had, and closing the tab lost it — with nothing to look at afterwards, a run
1082
+ * that failed overnight was a run nobody heard about.
1083
+ *
1084
+ * ⚠️ Two rules meet here and they are deliberately different ones. Whether the *flow* exists for
1085
+ * this actor is `read` or `execute`, exactly as the run path asks it — insisting on `read` would
1086
+ * make a library flow's own runner unable to find their run back. Which *runs* then appear is
1087
+ * `getRunVisible`, per row, in the repository: their own, plus every run of a flow they may
1088
+ * read. So an execute-only runner sees their own and nobody else's, and the answer cannot drift
1089
+ * from what opening a single run would say.
1090
+ *
1091
+ * What comes back is what a run did, never what it produced: `FlowRunSummary` carries no input
1092
+ * and no output, because a run reaches Knowledge and tools with the rights of whoever started it.
1093
+ */
1094
+ async listRuns(actor, input) {
1095
+ const flow = await requireRunnableFlow(actor, input.flowId);
1096
+ // One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
1097
+ // count over the whole table is never needed to draw a "next" affordance.
1098
+ const rows = await deps.repository.listRunsVisible(actor, {
1099
+ flowId: flow.id,
1100
+ failedOnly: input.failedOnly,
1101
+ limit: input.limit + 1,
1102
+ cursor: decodeCursor(input.cursor),
1103
+ });
1104
+ const page = rows.slice(0, input.limit);
1105
+ const failed = new Map((await deps.repository.failedSteps(page.map((run) => run.id))).map((row) => [
1106
+ row.runId,
1107
+ row,
1108
+ ]));
1109
+ // ⚠️ Four reads for a page, whatever its length: the runs, their failed steps, the versions
1110
+ // they took, and the call sites among those failures. Runs of one flow mostly share a version
1111
+ // and most failures are not calls, so all three follow-ups are asked once for the whole page
1112
+ // rather than once per line (#30).
1113
+ const versions = new Map((await deps.repository.getVersions([...new Set(page.map((run) => run.versionId))])).map((version) => [version.id, version]));
1114
+ const sites = [];
1115
+ for (const run of page) {
1116
+ const record = failed.get(run.id);
1117
+ const version = record ? versions.get(run.versionId) : undefined;
1118
+ if (record && version && nodeFor(version, record.nodeId)?.kind === "subflow") {
1119
+ sites.push({ runId: run.id, nodeId: record.nodeId });
1120
+ }
1121
+ }
1122
+ const calls = await visibleCalls(actor, sites);
1123
+ const items = [];
1124
+ for (const run of page) {
1125
+ const version = versions.get(run.versionId) ?? null;
1126
+ const record = failed.get(run.id);
1127
+ let failure = null;
1128
+ if (record) {
1129
+ const node = version ? nodeFor(version, record.nodeId) : null;
1130
+ const label = node?.label ?? record.nodeId;
1131
+ const stored = record.error ?? StepFailedDetail;
1132
+ const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
1133
+ failure = {
1134
+ nodeId: record.nodeId,
1135
+ nodeLabel: label,
1136
+ detail: node?.kind === "subflow"
1137
+ ? (callDetail(calledRunId, label, stored) ?? stored)
1138
+ : stored,
1139
+ calledRunId: calledRunId ?? null,
1140
+ };
1141
+ }
1142
+ items.push({
1143
+ id: run.id,
1144
+ flowId: run.flowId,
1145
+ versionId: run.versionId,
1146
+ status: run.status,
1147
+ // A version that has gone missing cannot say how the run was triggered, and `manual` is
1148
+ // what every run that was not a call was started by (#39).
1149
+ trigger: version ? triggerOf(version, run) : run.parentRunId ? "subflow" : "manual",
1150
+ startedAt: run.createdAt,
1151
+ completedAt: run.completedAt,
1152
+ durationMs: durationOf(run),
1153
+ initiatedBy: run.initiatedBy,
1154
+ parentRunId: run.parentRunId,
1155
+ failure,
1156
+ });
1157
+ }
1158
+ const last = page.at(-1);
1159
+ return {
1160
+ items,
1161
+ nextCursor: rows.length > page.length && last ? encodeCursor(last) : null,
1162
+ };
1163
+ },
1164
+ /**
1165
+ * One run step by step, which is the answer to "what went wrong" when the failure is three steps
1166
+ * in. The failed node is reported rather than drawn onto the canvas: a run took an immutable
1167
+ * version, the editor shows the current one, and marking a node in the wrong graph would point
1168
+ * confidently at the wrong step. Shipping the run's own graph is a bigger slice than #35 asks
1169
+ * for, so it stays a list of steps and the reason is written here rather than lost.
1170
+ *
1171
+ * ⚠️ `getRunVisible` alone opens this, and the current step is deliberately *not* re-authorized
1172
+ * the way `getRun` does it. `getRun` hands out the next thing to do and must therefore ask again
1173
+ * whether this person may still do it; history says what already happened, and a document
1174
+ * withdrawn since must not rewrite the record of a run that read it while it was granted.
1175
+ */
1176
+ async listRunSteps(actor, runId) {
1177
+ const run = await deps.repository.getRunVisible(actor, runId);
1178
+ if (!run)
1179
+ throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
1180
+ const version = await requireVersion(run.versionId, run.flowId);
1181
+ const records = await deps.repository.runSteps(run.id);
1182
+ // Every call this run made, asked once for the whole history rather than once per step (#30).
1183
+ const calls = await visibleCalls(actor, records
1184
+ .filter((record) => nodeFor(version, record.nodeId)?.kind === "subflow")
1185
+ .map((record) => ({ runId: run.id, nodeId: record.nodeId })));
1186
+ const steps = [];
1187
+ for (const record of records) {
1188
+ const node = nodeFor(version, record.nodeId);
1189
+ const label = node?.label ?? record.nodeId;
1190
+ const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
1191
+ const detail = node?.kind === "subflow" ? callDetail(calledRunId, label, record.error) : record.error;
1192
+ steps.push({
1193
+ nodeId: record.nodeId,
1194
+ nodeLabel: label,
1195
+ outcome: record.outcome,
1196
+ branch: record.branch,
1197
+ detail: record.outcome === "failed" ? (detail ?? StepFailedDetail) : detail,
1198
+ calledRunId: calledRunId ?? null,
1199
+ completedAt: record.completedAt,
1200
+ });
1201
+ }
1202
+ return {
1203
+ runId: run.id,
1204
+ flowId: run.flowId,
1205
+ status: run.status,
1206
+ steps,
1207
+ trail: await trailFor(run, version),
1208
+ };
1209
+ },
330
1210
  async completeStep(actor, input) {
331
1211
  requireRun(actor);
332
1212
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.complete", input.idempotencyKey);
@@ -338,6 +1218,9 @@ export function createFlows(deps) {
338
1218
  const current = await deps.repository.getRunVisible(actor, input.runId);
339
1219
  if (!current)
340
1220
  throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
1221
+ // Rechecked here rather than trusted from the start: a grant revoked while a run waits for an
1222
+ // approval must stop the next step, not only the next run.
1223
+ await requireExecute(actor, current.flowId);
341
1224
  if (current.status !== "running" && current.status !== "waiting") {
342
1225
  throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
343
1226
  }
@@ -352,24 +1235,55 @@ export function createFlows(deps) {
352
1235
  if (node.kind === "approval" && !actor.canApprove) {
353
1236
  throw new IntelError(403, "flow_approval_forbidden", "Approval permission is required");
354
1237
  }
1238
+ // Asked again before the result is recorded, not only when the step was handed out: a grant
1239
+ // revoked while the step was being carried out must stop it from landing.
1240
+ await requireNodeAuthorized(actor, node);
1241
+ // ⚠️ A subflow step is finished by the run it started, and by nothing the caller of this
1242
+ // surface says. Outcome, output and error are all read back out of that run: the outcome too,
1243
+ // or a client could mark a call that succeeded as failed and send the flow down a branch the
1244
+ // called flow never asked for. The state of a run belongs to the server.
1245
+ let outcome = input.outcome;
1246
+ let stepOutput = input.output;
1247
+ let stepError = input.error;
1248
+ if (node.kind === "subflow") {
1249
+ const called = await deps.repository.findChildRun(current.id, node.id);
1250
+ if (!called) {
1251
+ throw new IntelError(409, "flow_subflow_not_started", `Start the called flow before completing this step: ${node.label}`);
1252
+ }
1253
+ if (called.status === "completed") {
1254
+ outcome = "completed";
1255
+ stepOutput = called.output;
1256
+ stepError = null;
1257
+ }
1258
+ else if (called.status === "failed" || called.status === "cancelled") {
1259
+ outcome = "failed";
1260
+ stepOutput = null;
1261
+ stepError = called.error ?? calledStepDetail(node.label);
1262
+ }
1263
+ else {
1264
+ throw new IntelError(409, "flow_subflow_running", `The called flow is still running: ${node.label}`);
1265
+ }
1266
+ }
355
1267
  const occurredAt = deps.now().toISOString();
356
1268
  let status = "running";
357
1269
  let currentNodeId = null;
358
1270
  let output = current.output;
359
1271
  let error = null;
360
1272
  let completedAt = null;
361
- if (input.outcome === "failed") {
1273
+ if (outcome === "failed") {
362
1274
  status = "failed";
363
- error = input.error ?? "Flow step failed";
1275
+ error = stepError ?? StepFailedDetail;
364
1276
  completedAt = occurredAt;
365
1277
  }
366
1278
  else if (node.kind === "output") {
367
1279
  status = "completed";
368
- output = input.output;
1280
+ output = stepOutput;
369
1281
  completedAt = occurredAt;
370
1282
  }
371
1283
  else {
372
- const edges = version.graph.edges.filter((edge) => edge.source === node.id);
1284
+ // ⚠️ Flow edges only, for the same reason as the start above: what a step works with is not
1285
+ // where the run goes next (#37).
1286
+ const edges = version.graph.edges.filter((edge) => edge.kind === "flow" && edge.source === node.id);
373
1287
  const edge = node.kind === "condition" || node.kind === "approval"
374
1288
  ? edges.find((candidate) => candidate.sourceHandle === input.branch)
375
1289
  : edges[0];
@@ -392,10 +1306,10 @@ export function createFlows(deps) {
392
1306
  expectedNodeId: input.nodeId,
393
1307
  actorId: actor.id,
394
1308
  stepId: deps.id(),
395
- outcome: input.outcome,
1309
+ outcome,
396
1310
  branch: input.branch,
397
- output: input.output,
398
- error: input.error,
1311
+ output: stepOutput,
1312
+ error: stepError,
399
1313
  idempotencyKey: input.idempotencyKey,
400
1314
  auditId: deps.id(),
401
1315
  });
@@ -405,52 +1319,5 @@ export function createFlows(deps) {
405
1319
  await deps.runtime.signal(next.id);
406
1320
  return await step(actor, next);
407
1321
  },
408
- async listGrants(actor, flowId) {
409
- await requireManage(actor, flowId);
410
- return { items: await deps.repository.listGrants(flowId) };
411
- },
412
- async share(actor, input) {
413
- await requireManage(actor, input.resourceId);
414
- const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
415
- if (replayedId) {
416
- const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
417
- if (replayed)
418
- return replayed;
419
- }
420
- const principal = input.principal.type === "email"
421
- ? { type: "email", email: input.principal.email.toLowerCase() }
422
- : input.principal;
423
- const createdAt = deps.now().toISOString();
424
- return await deps.repository.setGrant({
425
- grant: {
426
- id: deps.id(),
427
- resourceId: input.resourceId,
428
- principal,
429
- role: input.role,
430
- expiresAt: input.expiresAt,
431
- createdBy: actor.id,
432
- createdAt,
433
- },
434
- actorId: actor.id,
435
- idempotencyKey: input.idempotencyKey,
436
- auditId: deps.id(),
437
- });
438
- },
439
- async revokeGrant(actor, input) {
440
- await requireManage(actor, input.resourceId);
441
- const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
442
- if (replayed !== null)
443
- return { revoked: replayed };
444
- return {
445
- revoked: await deps.repository.revokeGrant({
446
- flowId: input.resourceId,
447
- grantId: input.grantId,
448
- actorId: actor.id,
449
- idempotencyKey: input.idempotencyKey,
450
- auditId: deps.id(),
451
- occurredAt: deps.now().toISOString(),
452
- }),
453
- };
454
- },
455
1322
  };
456
1323
  }