@anchrd/intel-api 0.3.0 → 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.
- package/dist/adapters/cloudflare/cloudflare.js +12 -12
- package/dist/adapters/db/db-flows.js +417 -188
- package/dist/adapters/db/db-grants.d.ts +47 -0
- package/dist/adapters/db/db-grants.js +125 -0
- package/dist/adapters/db/db-indexing.js +14 -3
- package/dist/adapters/db/db.js +236 -202
- package/dist/flows/flows.d.ts +31 -1
- package/dist/flows/flows.js +988 -74
- package/dist/flows/flows.types.d.ts +104 -30
- package/dist/http/http.js +154 -46
- package/dist/indexing/indexing.js +14 -3
- package/dist/knowledge/document-links/document-links.d.ts +15 -0
- package/dist/knowledge/document-links/document-links.js +52 -0
- package/dist/knowledge/knowledge.js +357 -73
- package/dist/knowledge/knowledge.types.d.ts +60 -27
- package/dist/mcp/mcp.js +168 -74
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +1 -0
- package/dist/shared/gate-authorization/gate-authorization.js +7 -0
- package/dist/tools/tools.js +14 -1
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +39 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/package.json +1 -1
package/dist/flows/flows.js
CHANGED
|
@@ -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,35 @@ 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.
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
|
|
283
|
+
// second permission model here. `null` is the root and needs no permission of its own — the same
|
|
284
|
+
// as creating a folder at the root does.
|
|
285
|
+
async function requireFolder(actor, parentId) {
|
|
286
|
+
if (parentId === null)
|
|
287
|
+
return;
|
|
288
|
+
const access = await deps.folderAccess(actor, parentId);
|
|
289
|
+
if (access === "missing") {
|
|
290
|
+
throw new IntelError(404, "flow_parent_not_found", "Parent folder was not found");
|
|
291
|
+
}
|
|
292
|
+
if (access === "not-a-folder") {
|
|
293
|
+
throw new IntelError(409, "parent_not_folder", "A flow's parent must be a folder");
|
|
294
|
+
}
|
|
295
|
+
if (access === "forbidden") {
|
|
296
|
+
throw new IntelError(403, "knowledge_forbidden", "Destination folder cannot be edited");
|
|
103
297
|
}
|
|
104
|
-
return flow;
|
|
105
298
|
}
|
|
106
299
|
async function requireVersion(versionId, flowId) {
|
|
107
300
|
const version = await deps.repository.getVersion(versionId);
|
|
@@ -110,14 +303,295 @@ export function createFlows(deps) {
|
|
|
110
303
|
}
|
|
111
304
|
return version;
|
|
112
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
|
+
}
|
|
113
403
|
async function step(actor, run) {
|
|
114
|
-
await
|
|
404
|
+
await requireRunnableFlow(actor, run.flowId);
|
|
115
405
|
const version = await requireVersion(run.versionId, run.flowId);
|
|
116
|
-
|
|
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 };
|
|
117
591
|
}
|
|
118
592
|
return {
|
|
119
|
-
async list(actor) {
|
|
120
|
-
return { items: await deps.repository.listVisible(actor) };
|
|
593
|
+
async list(actor, input = {}) {
|
|
594
|
+
return { items: await deps.repository.listVisible(actor, input) };
|
|
121
595
|
},
|
|
122
596
|
async get(actor, flowId) {
|
|
123
597
|
const flow = await requireFlow(actor, flowId);
|
|
@@ -128,14 +602,223 @@ export function createFlows(deps) {
|
|
|
128
602
|
: null,
|
|
129
603
|
};
|
|
130
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
|
+
},
|
|
131
812
|
async create(actor, input) {
|
|
132
813
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
|
|
133
814
|
if (replayed)
|
|
134
815
|
return await requireFlow(actor, replayed);
|
|
816
|
+
await requireFolder(actor, input.parentId);
|
|
135
817
|
const occurredAt = deps.now().toISOString();
|
|
136
818
|
return await deps.repository.insertFlow({
|
|
137
819
|
flow: {
|
|
138
820
|
id: deps.id(),
|
|
821
|
+
parentId: input.parentId,
|
|
139
822
|
title: input.title,
|
|
140
823
|
description: input.description,
|
|
141
824
|
ownerId: actor.id,
|
|
@@ -150,6 +833,34 @@ export function createFlows(deps) {
|
|
|
150
833
|
auditId: deps.id(),
|
|
151
834
|
});
|
|
152
835
|
},
|
|
836
|
+
// Rename and move. It reads and writes the flow row alone: no version is appended, no published
|
|
837
|
+
// version is touched and no run is signalled, so tidying up the tree cannot change what a flow
|
|
838
|
+
// does (ADR-0004).
|
|
839
|
+
async update(actor, input) {
|
|
840
|
+
const current = await requireEdit(actor, input.flowId);
|
|
841
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.update", input.idempotencyKey);
|
|
842
|
+
if (replayed)
|
|
843
|
+
return await requireFlow(actor, replayed);
|
|
844
|
+
if (input.parentId !== undefined)
|
|
845
|
+
await requireFolder(actor, input.parentId);
|
|
846
|
+
const updated = await deps.repository.updateFlow({
|
|
847
|
+
flow: {
|
|
848
|
+
...current,
|
|
849
|
+
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
850
|
+
title: input.title ?? current.title,
|
|
851
|
+
description: input.description === undefined ? current.description : input.description,
|
|
852
|
+
updatedAt: deps.now().toISOString(),
|
|
853
|
+
},
|
|
854
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
855
|
+
actorId: actor.id,
|
|
856
|
+
idempotencyKey: input.idempotencyKey,
|
|
857
|
+
auditId: deps.id(),
|
|
858
|
+
});
|
|
859
|
+
if (updated === "conflict") {
|
|
860
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
861
|
+
}
|
|
862
|
+
return updated;
|
|
863
|
+
},
|
|
153
864
|
async save(actor, input) {
|
|
154
865
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
|
|
155
866
|
if (replayed) {
|
|
@@ -185,6 +896,18 @@ export function createFlows(deps) {
|
|
|
185
896
|
}
|
|
186
897
|
return { flow: await requireFlow(actor, flow.id), version };
|
|
187
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
|
+
},
|
|
188
911
|
async publish(actor, input) {
|
|
189
912
|
const flow = await requireEdit(actor, input.flowId);
|
|
190
913
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.publish", input.idempotencyKey);
|
|
@@ -194,8 +917,9 @@ export function createFlows(deps) {
|
|
|
194
917
|
compileFlow(version.graph);
|
|
195
918
|
for (const node of version.graph.nodes) {
|
|
196
919
|
if (node.kind === "knowledge") {
|
|
920
|
+
const reachable = new Set((await reachableKnowledge(actor, node.configuration.resourceIds)).map((reference) => reference.id));
|
|
197
921
|
for (const resourceId of node.configuration.resourceIds) {
|
|
198
|
-
if (!
|
|
922
|
+
if (!reachable.has(resourceId)) {
|
|
199
923
|
throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
|
|
200
924
|
}
|
|
201
925
|
}
|
|
@@ -210,9 +934,57 @@ export function createFlows(deps) {
|
|
|
210
934
|
}
|
|
211
935
|
}
|
|
212
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
|
+
}
|
|
213
985
|
const published = await deps.repository.publish({
|
|
214
986
|
flowId: flow.id,
|
|
215
|
-
versionId:
|
|
987
|
+
versionId: publishable.id,
|
|
216
988
|
actorId: actor.id,
|
|
217
989
|
idempotencyKey: input.idempotencyKey,
|
|
218
990
|
auditId: deps.id(),
|
|
@@ -224,6 +996,20 @@ export function createFlows(deps) {
|
|
|
224
996
|
},
|
|
225
997
|
async start(actor, input) {
|
|
226
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
|
+
}
|
|
227
1013
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.run", input.idempotencyKey);
|
|
228
1014
|
if (replayed) {
|
|
229
1015
|
const run = await deps.repository.getRunVisible(actor, replayed);
|
|
@@ -233,23 +1019,31 @@ export function createFlows(deps) {
|
|
|
233
1019
|
return await step(actor, run);
|
|
234
1020
|
}
|
|
235
1021
|
}
|
|
236
|
-
const flow = await
|
|
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.
|
|
237
1025
|
if (!flow.publishedVersionId) {
|
|
238
1026
|
throw new IntelError(409, "flow_not_published", "Flow has no published version");
|
|
239
1027
|
}
|
|
240
|
-
|
|
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);
|
|
241
1031
|
const compiled = compileFlow(version.graph);
|
|
242
|
-
|
|
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;
|
|
243
1035
|
if (!first)
|
|
244
1036
|
invalid("The trigger requires an outgoing edge");
|
|
245
1037
|
// The flow may have been built by someone with wider portal access. Naming the missing tools
|
|
246
|
-
// before the first step beats failing halfway through with a portal error the user cannot
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.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);
|
|
250
1041
|
if (missing.length) {
|
|
251
|
-
throw new IntelError(403, "flow_tools_unavailable",
|
|
1042
|
+
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
252
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));
|
|
253
1047
|
const occurredAt = deps.now().toISOString();
|
|
254
1048
|
const run = {
|
|
255
1049
|
id: deps.id(),
|
|
@@ -261,6 +1055,8 @@ export function createFlows(deps) {
|
|
|
261
1055
|
output: null,
|
|
262
1056
|
error: null,
|
|
263
1057
|
initiatedBy: actor.id,
|
|
1058
|
+
parentRunId: parent?.id ?? null,
|
|
1059
|
+
parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
|
|
264
1060
|
createdAt: occurredAt,
|
|
265
1061
|
updatedAt: occurredAt,
|
|
266
1062
|
completedAt: null,
|
|
@@ -280,6 +1076,137 @@ export function createFlows(deps) {
|
|
|
280
1076
|
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
281
1077
|
return await step(actor, run);
|
|
282
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
|
+
},
|
|
283
1210
|
async completeStep(actor, input) {
|
|
284
1211
|
requireRun(actor);
|
|
285
1212
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.complete", input.idempotencyKey);
|
|
@@ -291,6 +1218,9 @@ export function createFlows(deps) {
|
|
|
291
1218
|
const current = await deps.repository.getRunVisible(actor, input.runId);
|
|
292
1219
|
if (!current)
|
|
293
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);
|
|
294
1224
|
if (current.status !== "running" && current.status !== "waiting") {
|
|
295
1225
|
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
296
1226
|
}
|
|
@@ -305,24 +1235,55 @@ export function createFlows(deps) {
|
|
|
305
1235
|
if (node.kind === "approval" && !actor.canApprove) {
|
|
306
1236
|
throw new IntelError(403, "flow_approval_forbidden", "Approval permission is required");
|
|
307
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
|
+
}
|
|
308
1267
|
const occurredAt = deps.now().toISOString();
|
|
309
1268
|
let status = "running";
|
|
310
1269
|
let currentNodeId = null;
|
|
311
1270
|
let output = current.output;
|
|
312
1271
|
let error = null;
|
|
313
1272
|
let completedAt = null;
|
|
314
|
-
if (
|
|
1273
|
+
if (outcome === "failed") {
|
|
315
1274
|
status = "failed";
|
|
316
|
-
error =
|
|
1275
|
+
error = stepError ?? StepFailedDetail;
|
|
317
1276
|
completedAt = occurredAt;
|
|
318
1277
|
}
|
|
319
1278
|
else if (node.kind === "output") {
|
|
320
1279
|
status = "completed";
|
|
321
|
-
output =
|
|
1280
|
+
output = stepOutput;
|
|
322
1281
|
completedAt = occurredAt;
|
|
323
1282
|
}
|
|
324
1283
|
else {
|
|
325
|
-
|
|
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);
|
|
326
1287
|
const edge = node.kind === "condition" || node.kind === "approval"
|
|
327
1288
|
? edges.find((candidate) => candidate.sourceHandle === input.branch)
|
|
328
1289
|
: edges[0];
|
|
@@ -345,10 +1306,10 @@ export function createFlows(deps) {
|
|
|
345
1306
|
expectedNodeId: input.nodeId,
|
|
346
1307
|
actorId: actor.id,
|
|
347
1308
|
stepId: deps.id(),
|
|
348
|
-
outcome
|
|
1309
|
+
outcome,
|
|
349
1310
|
branch: input.branch,
|
|
350
|
-
output:
|
|
351
|
-
error:
|
|
1311
|
+
output: stepOutput,
|
|
1312
|
+
error: stepError,
|
|
352
1313
|
idempotencyKey: input.idempotencyKey,
|
|
353
1314
|
auditId: deps.id(),
|
|
354
1315
|
});
|
|
@@ -358,52 +1319,5 @@ export function createFlows(deps) {
|
|
|
358
1319
|
await deps.runtime.signal(next.id);
|
|
359
1320
|
return await step(actor, next);
|
|
360
1321
|
},
|
|
361
|
-
async listGrants(actor, flowId) {
|
|
362
|
-
await requireManage(actor, flowId);
|
|
363
|
-
return { items: await deps.repository.listGrants(flowId) };
|
|
364
|
-
},
|
|
365
|
-
async share(actor, input) {
|
|
366
|
-
await requireManage(actor, input.resourceId);
|
|
367
|
-
const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
|
|
368
|
-
if (replayedId) {
|
|
369
|
-
const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
|
|
370
|
-
if (replayed)
|
|
371
|
-
return replayed;
|
|
372
|
-
}
|
|
373
|
-
const principal = input.principal.type === "email"
|
|
374
|
-
? { type: "email", email: input.principal.email.toLowerCase() }
|
|
375
|
-
: input.principal;
|
|
376
|
-
const createdAt = deps.now().toISOString();
|
|
377
|
-
return await deps.repository.setGrant({
|
|
378
|
-
grant: {
|
|
379
|
-
id: deps.id(),
|
|
380
|
-
resourceId: input.resourceId,
|
|
381
|
-
principal,
|
|
382
|
-
role: input.role,
|
|
383
|
-
expiresAt: input.expiresAt,
|
|
384
|
-
createdBy: actor.id,
|
|
385
|
-
createdAt,
|
|
386
|
-
},
|
|
387
|
-
actorId: actor.id,
|
|
388
|
-
idempotencyKey: input.idempotencyKey,
|
|
389
|
-
auditId: deps.id(),
|
|
390
|
-
});
|
|
391
|
-
},
|
|
392
|
-
async revokeGrant(actor, input) {
|
|
393
|
-
await requireManage(actor, input.resourceId);
|
|
394
|
-
const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
|
|
395
|
-
if (replayed !== null)
|
|
396
|
-
return { revoked: replayed };
|
|
397
|
-
return {
|
|
398
|
-
revoked: await deps.repository.revokeGrant({
|
|
399
|
-
flowId: input.resourceId,
|
|
400
|
-
grantId: input.grantId,
|
|
401
|
-
actorId: actor.id,
|
|
402
|
-
idempotencyKey: input.idempotencyKey,
|
|
403
|
-
auditId: deps.id(),
|
|
404
|
-
occurredAt: deps.now().toISOString(),
|
|
405
|
-
}),
|
|
406
|
-
};
|
|
407
|
-
},
|
|
408
1322
|
};
|
|
409
1323
|
}
|