@anchrd/intel-api 0.4.0 → 0.5.1
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/db/db.js +11 -9
- package/dist/cli/cli.js +8 -4
- package/dist/flows/flows.d.ts +4 -1
- package/dist/flows/flows.js +224 -77
- package/dist/flows/flows.types.d.ts +7 -2
- package/dist/http/http.js +9 -1
- package/dist/knowledge/knowledge.js +0 -2
- package/dist/mcp/mcp.js +12 -1
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +33 -0
- package/package.json +2 -2
package/dist/adapters/db/db.js
CHANGED
|
@@ -3,7 +3,7 @@ const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_a
|
|
|
3
3
|
created_by, created_at`;
|
|
4
4
|
const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
|
|
5
5
|
link.origin, link.label, link.created_by, link.created_at`;
|
|
6
|
-
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
6
|
+
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
7
7
|
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
|
|
8
8
|
function mapNode(row) {
|
|
9
9
|
return {
|
|
@@ -12,7 +12,6 @@ function mapNode(row) {
|
|
|
12
12
|
kind: row.kind,
|
|
13
13
|
title: row.title,
|
|
14
14
|
description: row.description,
|
|
15
|
-
contextPolicy: row.context_policy,
|
|
16
15
|
ownerId: row.owner_id,
|
|
17
16
|
currentVersionId: row.current_version_id,
|
|
18
17
|
createdAt: row.created_at,
|
|
@@ -152,11 +151,15 @@ export function createKnowledgeRepository(deps) {
|
|
|
152
151
|
try {
|
|
153
152
|
await deps.db.batch([
|
|
154
153
|
deps.db
|
|
154
|
+
// ⚠️ `context_policy` is dead and is written anyway (#76). The column is NOT NULL
|
|
155
|
+
// without a DEFAULT and D1 will not let it be dropped — migration 0009 carries the
|
|
156
|
+
// reason. The fixed value is the price; nothing reads it, and the contract no longer
|
|
157
|
+
// knows the field. When the column goes (anchrd/intel#86), this line goes with it.
|
|
155
158
|
.prepare(`INSERT INTO knowledge_nodes (
|
|
156
159
|
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
157
160
|
current_version_id, created_at, updated_at, archived_at
|
|
158
|
-
) VALUES (?, ?, ?, ?, ?,
|
|
159
|
-
.bind(node.id, node.parentId, node.kind, node.title, node.description, node.
|
|
161
|
+
) VALUES (?, ?, ?, ?, ?, 'relevant', ?, ?, ?, ?, ?)`)
|
|
162
|
+
.bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
|
|
160
163
|
deps.db
|
|
161
164
|
.prepare(`INSERT INTO idempotency_keys (
|
|
162
165
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -239,10 +242,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
239
242
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
240
243
|
)
|
|
241
244
|
UPDATE knowledge_nodes
|
|
242
|
-
SET parent_id = ?, title = ?, description = ?,
|
|
245
|
+
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
243
246
|
WHERE id = ? AND updated_at = ?
|
|
244
247
|
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
|
|
245
|
-
.bind(node.id, node.parentId, node.title, node.description, node.
|
|
248
|
+
.bind(node.id, node.parentId, node.title, node.description, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId),
|
|
246
249
|
deps.db
|
|
247
250
|
.prepare(`INSERT INTO idempotency_keys (
|
|
248
251
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -250,9 +253,9 @@ export function createKnowledgeRepository(deps) {
|
|
|
250
253
|
WHERE EXISTS (
|
|
251
254
|
SELECT 1 FROM knowledge_nodes
|
|
252
255
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
253
|
-
AND
|
|
256
|
+
AND updated_at = ?
|
|
254
257
|
)`)
|
|
255
|
-
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.
|
|
258
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
|
|
256
259
|
deps.db
|
|
257
260
|
.prepare(`INSERT INTO audit_events (
|
|
258
261
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -265,7 +268,6 @@ export function createKnowledgeRepository(deps) {
|
|
|
265
268
|
.bind(input.auditId, input.actorId, node.id, JSON.stringify({
|
|
266
269
|
parentId: node.parentId,
|
|
267
270
|
title: node.title,
|
|
268
|
-
contextPolicy: node.contextPolicy,
|
|
269
271
|
}), node.updatedAt, input.actorId, input.idempotencyKey, node.id),
|
|
270
272
|
]);
|
|
271
273
|
}
|
package/dist/cli/cli.js
CHANGED
|
@@ -3,14 +3,18 @@ import { createPrepare } from "../prepare/prepare.js";
|
|
|
3
3
|
const interfaces = [
|
|
4
4
|
{ handle: "intel", functions: ["use", "admin"] },
|
|
5
5
|
{ handle: "knowledge", functions: ["read", "create", "write", "share"] },
|
|
6
|
+
// ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
|
|
7
|
+
// a function nobody asks about is a permission an operator has to decide on for no reason. An
|
|
8
|
+
// installation that already granted it keeps a harmless leftover — bootstrap declares, it does
|
|
9
|
+
// not revoke.
|
|
6
10
|
{
|
|
7
11
|
handle: "flows",
|
|
8
|
-
functions: ["read", "create", "write", "publish", "run", "
|
|
12
|
+
functions: ["read", "create", "write", "publish", "run", "share"],
|
|
9
13
|
},
|
|
10
14
|
{ handle: "tools", functions: ["read", "test", "execute", "admin"] },
|
|
11
|
-
// `mcp:connect`
|
|
12
|
-
//
|
|
13
|
-
//
|
|
15
|
+
// `mcp:connect` means the same thing at every MCP service of this installation. A separate name
|
|
16
|
+
// for the same thing forces the operator to check, service by service, which permission carries
|
|
17
|
+
// portal access.
|
|
14
18
|
{ handle: "mcp", functions: ["connect"] },
|
|
15
19
|
];
|
|
16
20
|
const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
|
package/dist/flows/flows.d.ts
CHANGED
|
@@ -4,11 +4,13 @@ type SubflowNode = Extract<FlowNode, {
|
|
|
4
4
|
kind: "subflow";
|
|
5
5
|
}>;
|
|
6
6
|
type KnowledgeStepNode = Extract<FlowNode, {
|
|
7
|
-
kind:
|
|
7
|
+
kind: KnowledgeLinkKind;
|
|
8
8
|
}>;
|
|
9
9
|
type ToolStepNode = Extract<FlowNode, {
|
|
10
10
|
kind: "tool";
|
|
11
11
|
}>;
|
|
12
|
+
declare const knowledgeLinkKinds: readonly ["folder", "document", "upload", "table"];
|
|
13
|
+
type KnowledgeLinkKind = (typeof knowledgeLinkKinds)[number];
|
|
12
14
|
/**
|
|
13
15
|
* One place reads a graph for each kind of step it contains, and everything else is derived from
|
|
14
16
|
* these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
|
|
@@ -18,6 +20,7 @@ type ToolStepNode = Extract<FlowNode, {
|
|
|
18
20
|
export declare function subflowNodes(graph: FlowGraph): SubflowNode[];
|
|
19
21
|
export declare function knowledgeNodes(graph: FlowGraph): KnowledgeStepNode[];
|
|
20
22
|
export declare function toolNodes(graph: FlowGraph): ToolStepNode[];
|
|
23
|
+
export declare function resourceIdOf(node: KnowledgeStepNode): string;
|
|
21
24
|
export declare function calleeIds(graph: FlowGraph): string[];
|
|
22
25
|
/**
|
|
23
26
|
* The Knowledge documents a graph names and the tools it calls, flattened and without repetition.
|
package/dist/flows/flows.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { flowNodeLayer } from "@anchrd/intel-contract";
|
|
1
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
3
|
function invalid(detail) {
|
|
3
4
|
throw new IntelError(400, "flow_graph_invalid", detail);
|
|
@@ -5,6 +6,10 @@ function invalid(detail) {
|
|
|
5
6
|
// A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
|
|
6
7
|
// walk and the run trail, both of which follow data that other writers can change.
|
|
7
8
|
const MaxCallDepth = 20;
|
|
9
|
+
// The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
|
|
10
|
+
// names a portal tool rather than a resource, and every rule about reachability applies to these
|
|
11
|
+
// four and not to it.
|
|
12
|
+
const knowledgeLinkKinds = ["folder", "document", "upload", "table"];
|
|
8
13
|
/**
|
|
9
14
|
* One place reads a graph for each kind of step it contains, and everything else is derived from
|
|
10
15
|
* these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
|
|
@@ -14,12 +19,20 @@ const MaxCallDepth = 20;
|
|
|
14
19
|
export function subflowNodes(graph) {
|
|
15
20
|
return graph.nodes.filter((node) => node.kind === "subflow");
|
|
16
21
|
}
|
|
22
|
+
// ⚠️ Kept as one list although it is now four kinds: every caller asks "what does this graph read
|
|
23
|
+
// from the tree", never "which of the four". Splitting the callers to match the split in the schema
|
|
24
|
+
// would multiply four ways at each of the six call sites for no answer anyone wants.
|
|
17
25
|
export function knowledgeNodes(graph) {
|
|
18
|
-
return graph.nodes.filter((node) => node.kind
|
|
26
|
+
return graph.nodes.filter((node) => knowledgeLinkKinds.includes(node.kind));
|
|
19
27
|
}
|
|
20
28
|
export function toolNodes(graph) {
|
|
21
29
|
return graph.nodes.filter((node) => node.kind === "tool");
|
|
22
30
|
}
|
|
31
|
+
// The one resource a link names. Its own function because four kinds answer it the same way, and a
|
|
32
|
+
// caller that switched on the kind to read the same field would invite a fifth kind to be forgotten.
|
|
33
|
+
export function resourceIdOf(node) {
|
|
34
|
+
return node.configuration.resourceId;
|
|
35
|
+
}
|
|
23
36
|
// The flows a graph calls, in the order the nodes name them and without repetition. `subflowNodes`
|
|
24
37
|
// answers "which calls", this answers "which flows" — a flow called twice with two different version
|
|
25
38
|
// choices is two calls and one callee, and the freeze has to see both.
|
|
@@ -41,9 +54,8 @@ export function graphReferences(graph) {
|
|
|
41
54
|
const knowledge = [];
|
|
42
55
|
const tools = [];
|
|
43
56
|
for (const node of knowledgeNodes(graph)) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
knowledge.push(resourceId);
|
|
57
|
+
if (!knowledge.includes(node.configuration.resourceId)) {
|
|
58
|
+
knowledge.push(node.configuration.resourceId);
|
|
47
59
|
}
|
|
48
60
|
}
|
|
49
61
|
for (const node of toolNodes(graph)) {
|
|
@@ -118,13 +130,30 @@ export function compileFlow(graph) {
|
|
|
118
130
|
for (const node of graph.nodes) {
|
|
119
131
|
const parents = incoming.get(node.id) ?? 0;
|
|
120
132
|
const children = outgoing.get(node.id) ?? [];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
133
|
+
const layer = flowNodeLayer[node.kind];
|
|
134
|
+
const holderId = attachedTo.get(node.id);
|
|
135
|
+
// ⚠️ The layer rule of D25, and the reason it lives here rather than in the editor: a graph
|
|
136
|
+
// arrives over MCP as readily as from the canvas. A link is what a step works with and is never
|
|
137
|
+
// in the order of work; a marker and a step are the order of work and are never material.
|
|
138
|
+
// Before this, a link could stand in the chain — which is how a start with an attachment once
|
|
139
|
+
// began its run at the attachment (#37).
|
|
140
|
+
if (layer === "link" && holderId === undefined) {
|
|
141
|
+
invalid(`Node ${node.id} is a link and must be attached to a step`);
|
|
142
|
+
}
|
|
143
|
+
if (layer !== "link" && holderId !== undefined) {
|
|
144
|
+
invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
|
|
145
|
+
}
|
|
146
|
+
if (holderId !== undefined) {
|
|
125
147
|
if (parents !== 0 || children.length !== 0) {
|
|
126
148
|
invalid(`Node ${node.id} is attached as context and cannot also be a step`);
|
|
127
149
|
}
|
|
150
|
+
const holder = nodes.get(holderId);
|
|
151
|
+
// Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
|
|
152
|
+
// flow it names brings its own links, and lending it one from here would be steering another
|
|
153
|
+
// flow from outside.
|
|
154
|
+
if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
|
|
155
|
+
invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
|
|
156
|
+
}
|
|
128
157
|
continue;
|
|
129
158
|
}
|
|
130
159
|
if (node.kind === "trigger" && parents !== 0)
|
|
@@ -134,7 +163,7 @@ export function compileFlow(graph) {
|
|
|
134
163
|
}
|
|
135
164
|
if (node.kind === "output" && children.length !== 0)
|
|
136
165
|
invalid(`Output ${node.id} must be terminal`);
|
|
137
|
-
if (node.kind === "condition"
|
|
166
|
+
if (node.kind === "condition") {
|
|
138
167
|
if (children.length < 2)
|
|
139
168
|
invalid(`Node ${node.id} requires at least two branches`);
|
|
140
169
|
const handles = new Set(children.map((edge) => edge.sourceHandle));
|
|
@@ -279,6 +308,95 @@ export function createFlows(deps) {
|
|
|
279
308
|
throw new IntelError(403, "flow_execute_forbidden", "Flow execute permission is required");
|
|
280
309
|
}
|
|
281
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Everything that stands between this actor and a run of this flow, gathered rather than thrown.
|
|
313
|
+
*
|
|
314
|
+
* ⚠️ THE reason this function exists rather than a second list beside `start`: two lists drift.
|
|
315
|
+
* They drift silently, and the direction they drift in is the worst one — `validate` keeps saying
|
|
316
|
+
* "it would run" while `start` refuses, which sends the reader looking anywhere but at the
|
|
317
|
+
* problem. `start` calls this and throws on the first entry; `validate` calls it and returns all
|
|
318
|
+
* of them. A check added here is therefore added to both, or to neither.
|
|
319
|
+
*/
|
|
320
|
+
async function collectRunProblems(actor, flow, pinnedVersionId) {
|
|
321
|
+
const problems = [];
|
|
322
|
+
if (!(await deps.repository.can(actor, flow.id, "execute"))) {
|
|
323
|
+
problems.push({
|
|
324
|
+
status: 403,
|
|
325
|
+
code: "flow_execute_forbidden",
|
|
326
|
+
detail: "Flow execute permission is required",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// Asked even for a frozen call: a flow whose publication was withdrawn is not something a
|
|
330
|
+
// pinned version may quietly keep running.
|
|
331
|
+
if (!flow.publishedVersionId) {
|
|
332
|
+
problems.push({
|
|
333
|
+
status: 409,
|
|
334
|
+
code: "flow_not_published",
|
|
335
|
+
detail: "Flow has no published version",
|
|
336
|
+
});
|
|
337
|
+
return { problems, version: null, first: null };
|
|
338
|
+
}
|
|
339
|
+
// A frozen call runs its own version; everything else runs what is published now.
|
|
340
|
+
const version = await requireVersion(pinnedVersionId ?? flow.publishedVersionId, flow.id);
|
|
341
|
+
let first = null;
|
|
342
|
+
try {
|
|
343
|
+
const compiled = compileFlow(version.graph);
|
|
344
|
+
// ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
|
|
345
|
+
// node, because that edge can come first in the list (#37).
|
|
346
|
+
first =
|
|
347
|
+
version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target ?? null;
|
|
348
|
+
if (!first)
|
|
349
|
+
invalid("The trigger requires an outgoing edge");
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
problems.push({
|
|
353
|
+
status: 400,
|
|
354
|
+
code: "flow_graph_invalid",
|
|
355
|
+
detail: error instanceof IntelError ? error.message : "The graph cannot be compiled",
|
|
356
|
+
});
|
|
357
|
+
return { problems, version, first: null };
|
|
358
|
+
}
|
|
359
|
+
// The flow may have been built by someone with wider portal access. Naming the missing tools
|
|
360
|
+
// before the first step beats failing halfway through with a portal error the user cannot
|
|
361
|
+
// place — and the portal is where they can do something about it.
|
|
362
|
+
const missing = await deps.unavailableTools(actor, graphReferences(version.graph).tools);
|
|
363
|
+
if (missing.length) {
|
|
364
|
+
problems.push({
|
|
365
|
+
status: 403,
|
|
366
|
+
code: "flow_tools_unavailable",
|
|
367
|
+
detail: toolStepDetail(missing),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
await requireNodeAuthorized(actor, nodeFor(version, first), version.graph);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (!(error instanceof IntelError))
|
|
375
|
+
throw error;
|
|
376
|
+
problems.push({ status: error.status, code: error.code, detail: error.message });
|
|
377
|
+
}
|
|
378
|
+
// Every flow this one calls has to be published, or the call fails halfway through a run rather
|
|
379
|
+
// than before it. `publish` refuses this at freeze time; a flow whose callee was un-published
|
|
380
|
+
// afterwards is the case only a check on demand can catch.
|
|
381
|
+
for (const node of subflowNodes(version.graph)) {
|
|
382
|
+
const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
|
|
383
|
+
if (!callee || callee.archivedAt) {
|
|
384
|
+
problems.push({
|
|
385
|
+
status: 409,
|
|
386
|
+
code: "flow_subflow_unavailable",
|
|
387
|
+
detail: `The called flow is not available: ${node.label}`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
else if (!callee.publishedVersionId) {
|
|
391
|
+
problems.push({
|
|
392
|
+
status: 409,
|
|
393
|
+
code: "flow_subflow_not_published",
|
|
394
|
+
detail: `The called flow has no published version: ${node.label}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { problems, version, first };
|
|
399
|
+
}
|
|
282
400
|
// A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
|
|
283
401
|
// second permission model here. `null` is the root and needs no permission of its own — the same
|
|
284
402
|
// as creating a folder at the root does.
|
|
@@ -363,16 +481,27 @@ export function createFlows(deps) {
|
|
|
363
481
|
// current Gate identity and the resource ACL are asked again. A called flow's steps come through
|
|
364
482
|
// this same function, so a document the user may not read stays unreadable however deep the call
|
|
365
483
|
// sits, and a flow grant keeps protecting the procedure rather than the data.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
484
|
+
// ⚠️ Reads what hangs off the step, not the step itself (D25). A document or a tool is a link on a
|
|
485
|
+
// `context` edge now, and a run never stands on one — so asking "is this node a knowledge step"
|
|
486
|
+
// would ask about a node the run can no longer reach, and every check here would silently pass.
|
|
487
|
+
async function requireNodeAuthorized(actor, node, graph) {
|
|
488
|
+
if (!node)
|
|
489
|
+
return;
|
|
490
|
+
const attached = graph.edges
|
|
491
|
+
.filter((edge) => edge.kind === "context" && edge.source === node.id)
|
|
492
|
+
.flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
|
|
493
|
+
const wanted = [...new Set(knowledgeNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
|
|
494
|
+
if (wanted.length) {
|
|
369
495
|
const reachable = await reachableKnowledge(actor, wanted);
|
|
370
496
|
if (reachable.length !== wanted.length) {
|
|
371
497
|
throw new IntelError(403, "flow_knowledge_forbidden", knowledgeStepDetail(node.label, wanted.length - reachable.length));
|
|
372
498
|
}
|
|
373
499
|
}
|
|
374
|
-
|
|
375
|
-
|
|
500
|
+
const toolNames = attached
|
|
501
|
+
.filter((candidate) => candidate.kind === "tool")
|
|
502
|
+
.map((candidate) => candidate.configuration.toolName);
|
|
503
|
+
if (toolNames.length) {
|
|
504
|
+
const missing = await deps.unavailableTools(actor, [...new Set(toolNames)]);
|
|
376
505
|
if (missing.length) {
|
|
377
506
|
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
378
507
|
}
|
|
@@ -404,7 +533,7 @@ export function createFlows(deps) {
|
|
|
404
533
|
await requireRunnableFlow(actor, run.flowId);
|
|
405
534
|
const version = await requireVersion(run.versionId, run.flowId);
|
|
406
535
|
const node = nodeFor(version, run.currentNodeId);
|
|
407
|
-
await requireNodeAuthorized(actor, node);
|
|
536
|
+
await requireNodeAuthorized(actor, node, version.graph);
|
|
408
537
|
return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
|
|
409
538
|
}
|
|
410
539
|
// A failed run carries the text of the step that failed, and for a call that text came out of
|
|
@@ -571,7 +700,7 @@ export function createFlows(deps) {
|
|
|
571
700
|
throw new IntelError(403, "flow_call_site_forbidden", "The calling run belongs to someone else");
|
|
572
701
|
}
|
|
573
702
|
await requireExecute(actor, run.flowId);
|
|
574
|
-
if (run.status !== "running"
|
|
703
|
+
if (run.status !== "running") {
|
|
575
704
|
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
576
705
|
}
|
|
577
706
|
if (run.currentNodeId !== parent.nodeId) {
|
|
@@ -715,17 +844,16 @@ export function createFlows(deps) {
|
|
|
715
844
|
// name the step it comes from, so this is the one caller that needs the nodes themselves
|
|
716
845
|
// and not the flattened `graphReferences`.
|
|
717
846
|
for (const node of knowledgeNodes(version.graph)) {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
847
|
+
const resourceId = resourceIdOf(node);
|
|
848
|
+
const target = await knowledgeNode(resourceId);
|
|
849
|
+
if (!target || !place(target.node, target.parentId))
|
|
850
|
+
continue;
|
|
851
|
+
edges.push({
|
|
852
|
+
id: `reads:${flow.id}:${node.id}:${resourceId}`,
|
|
853
|
+
source: flow.id,
|
|
854
|
+
target: resourceId,
|
|
855
|
+
relation: "reads",
|
|
856
|
+
});
|
|
729
857
|
}
|
|
730
858
|
for (const node of subflowNodes(version.graph)) {
|
|
731
859
|
const callee = calleeFlow(node.configuration.flowId);
|
|
@@ -779,6 +907,28 @@ export function createFlows(deps) {
|
|
|
779
907
|
// and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
|
|
780
908
|
// would be wrong for tools by construction — the catalog is a live query with the requesting
|
|
781
909
|
// user's own token (ADR-0003) — and out of date for documents most of the time.
|
|
910
|
+
/**
|
|
911
|
+
* Would this flow start, for the person asking, right now? It answers and changes nothing: no
|
|
912
|
+
* run row, no idempotency key, no audit event (#72).
|
|
913
|
+
*
|
|
914
|
+
* ⚠️ `read` is enough, deliberately. Somebody who may see a flow but not run it is exactly who
|
|
915
|
+
* needs this — "you are missing execute" is the answer they came for, and refusing to answer
|
|
916
|
+
* would leave them guessing at the one thing that is easy to say. Nothing is named here that a
|
|
917
|
+
* reader could not already read out of the graph.
|
|
918
|
+
*
|
|
919
|
+
* ⚠️ Not cached and not stored anywhere. The tool catalog is a live query with the asking
|
|
920
|
+
* user's own token (ADR-0003); a kept answer would be a claim about a moment that has passed.
|
|
921
|
+
*/
|
|
922
|
+
async validate(actor, flowId) {
|
|
923
|
+
const flow = await requireRunnableFlow(actor, flowId);
|
|
924
|
+
const { problems, version } = await collectRunProblems(actor, flow, null);
|
|
925
|
+
return {
|
|
926
|
+
flowId: flow.id,
|
|
927
|
+
versionId: version?.id ?? null,
|
|
928
|
+
problems: problems.map(({ code, detail }) => ({ code, detail })),
|
|
929
|
+
checkedAt: deps.now().toISOString(),
|
|
930
|
+
};
|
|
931
|
+
},
|
|
782
932
|
async listRequirements(actor, flowId) {
|
|
783
933
|
const flow = await requireFlow(actor, flowId);
|
|
784
934
|
// The draft is what the editor is looking at; a flow with only a published version has
|
|
@@ -915,15 +1065,14 @@ export function createFlows(deps) {
|
|
|
915
1065
|
return await requireFlow(actor, flow.id);
|
|
916
1066
|
const version = await requireVersion(input.versionId, flow.id);
|
|
917
1067
|
compileFlow(version.graph);
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
1068
|
+
const wanted = [...new Set(knowledgeNodes(version.graph).map(resourceIdOf))];
|
|
1069
|
+
const reachable = new Set((await reachableKnowledge(actor, wanted)).map((reference) => reference.id));
|
|
1070
|
+
for (const resourceId of wanted) {
|
|
1071
|
+
if (!reachable.has(resourceId)) {
|
|
1072
|
+
throw new IntelError(409, "flow_knowledge_unavailable", `Knowledge reference is unavailable: ${resourceId}`);
|
|
926
1073
|
}
|
|
1074
|
+
}
|
|
1075
|
+
for (const node of version.graph.nodes) {
|
|
927
1076
|
if (node.kind === "tool") {
|
|
928
1077
|
const fingerprint = await deps.toolFingerprint(actor, node.configuration.toolName);
|
|
929
1078
|
if (!fingerprint) {
|
|
@@ -1020,46 +1169,41 @@ export function createFlows(deps) {
|
|
|
1020
1169
|
}
|
|
1021
1170
|
}
|
|
1022
1171
|
const flow = await requireRunnableFlow(actor, input.flowId);
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
const first =
|
|
1035
|
-
if (!first)
|
|
1036
|
-
invalid("The
|
|
1037
|
-
// The flow may have been built by someone with wider portal access. Naming the missing tools
|
|
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);
|
|
1041
|
-
if (missing.length) {
|
|
1042
|
-
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
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));
|
|
1172
|
+
// ⚠️ The same list `validate` reads, and the reason it is one list: a check that only one of
|
|
1173
|
+
// them knows is a check the other silently skips. Thrown here at the first entry, because a
|
|
1174
|
+
// start is an action and an action either happens or says why not.
|
|
1175
|
+
//
|
|
1176
|
+
// Nothing has been written at this point, so a refusal leaves nothing behind at all — not a
|
|
1177
|
+
// run, not an idempotency key, not an audit event.
|
|
1178
|
+
const check = await collectRunProblems(actor, flow, call?.versionId ?? null);
|
|
1179
|
+
const problem = check.problems[0];
|
|
1180
|
+
if (problem)
|
|
1181
|
+
throw new IntelError(problem.status, problem.code, problem.detail);
|
|
1182
|
+
const version = check.version;
|
|
1183
|
+
const first = check.first;
|
|
1184
|
+
if (!version || !first)
|
|
1185
|
+
invalid("The flow cannot be started");
|
|
1047
1186
|
const occurredAt = deps.now().toISOString();
|
|
1187
|
+
// ⚠️ A flow whose start leads straight to its end has nothing to carry out, and the end is a
|
|
1188
|
+
// marker rather than a step (D25). It is the shape every flow has the moment it is created,
|
|
1189
|
+
// so it must run rather than park a run on a node nobody can complete. What it hands back is
|
|
1190
|
+
// its input: the only thing that passed through it.
|
|
1191
|
+
const empty = nodeFor(version, first)?.kind === "output";
|
|
1048
1192
|
const run = {
|
|
1049
1193
|
id: deps.id(),
|
|
1050
1194
|
flowId: flow.id,
|
|
1051
1195
|
versionId: version.id,
|
|
1052
|
-
status: "running",
|
|
1053
|
-
currentNodeId: first,
|
|
1196
|
+
status: empty ? "completed" : "running",
|
|
1197
|
+
currentNodeId: empty ? null : first,
|
|
1054
1198
|
input: input.input,
|
|
1055
|
-
output: null,
|
|
1199
|
+
output: empty ? input.input : null,
|
|
1056
1200
|
error: null,
|
|
1057
1201
|
initiatedBy: actor.id,
|
|
1058
1202
|
parentRunId: parent?.id ?? null,
|
|
1059
1203
|
parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
|
|
1060
1204
|
createdAt: occurredAt,
|
|
1061
1205
|
updatedAt: occurredAt,
|
|
1062
|
-
completedAt: null,
|
|
1206
|
+
completedAt: empty ? occurredAt : null,
|
|
1063
1207
|
};
|
|
1064
1208
|
const inserted = await deps.repository.insertRun({
|
|
1065
1209
|
run,
|
|
@@ -1218,10 +1362,10 @@ export function createFlows(deps) {
|
|
|
1218
1362
|
const current = await deps.repository.getRunVisible(actor, input.runId);
|
|
1219
1363
|
if (!current)
|
|
1220
1364
|
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
|
|
1222
|
-
//
|
|
1365
|
+
// Rechecked here rather than trusted from the start: a grant revoked between two steps must
|
|
1366
|
+
// stop the next one, not only the next run.
|
|
1223
1367
|
await requireExecute(actor, current.flowId);
|
|
1224
|
-
if (current.status !== "running"
|
|
1368
|
+
if (current.status !== "running") {
|
|
1225
1369
|
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
1226
1370
|
}
|
|
1227
1371
|
if (current.currentNodeId !== input.nodeId) {
|
|
@@ -1232,12 +1376,9 @@ export function createFlows(deps) {
|
|
|
1232
1376
|
if (!node) {
|
|
1233
1377
|
throw new IntelError(500, "flow_version_corrupt", "Current run node is missing from the immutable version");
|
|
1234
1378
|
}
|
|
1235
|
-
if (node.kind === "approval" && !actor.canApprove) {
|
|
1236
|
-
throw new IntelError(403, "flow_approval_forbidden", "Approval permission is required");
|
|
1237
|
-
}
|
|
1238
1379
|
// Asked again before the result is recorded, not only when the step was handed out: a grant
|
|
1239
1380
|
// revoked while the step was being carried out must stop it from landing.
|
|
1240
|
-
await requireNodeAuthorized(actor, node);
|
|
1381
|
+
await requireNodeAuthorized(actor, node, version.graph);
|
|
1241
1382
|
// ⚠️ A subflow step is finished by the run it started, and by nothing the caller of this
|
|
1242
1383
|
// surface says. Outcome, output and error are all read back out of that run: the outcome too,
|
|
1243
1384
|
// or a client could mark a call that succeeded as failed and send the flow down a branch the
|
|
@@ -1275,22 +1416,28 @@ export function createFlows(deps) {
|
|
|
1275
1416
|
error = stepError ?? StepFailedDetail;
|
|
1276
1417
|
completedAt = occurredAt;
|
|
1277
1418
|
}
|
|
1278
|
-
else if (node.kind === "output") {
|
|
1279
|
-
status = "completed";
|
|
1280
|
-
output = stepOutput;
|
|
1281
|
-
completedAt = occurredAt;
|
|
1282
|
-
}
|
|
1283
1419
|
else {
|
|
1284
1420
|
// ⚠️ Flow edges only, for the same reason as the start above: what a step works with is not
|
|
1285
1421
|
// where the run goes next (#37).
|
|
1286
1422
|
const edges = version.graph.edges.filter((edge) => edge.kind === "flow" && edge.source === node.id);
|
|
1287
|
-
const edge = node.kind === "condition"
|
|
1423
|
+
const edge = node.kind === "condition"
|
|
1288
1424
|
? edges.find((candidate) => candidate.sourceHandle === input.branch)
|
|
1289
1425
|
: edges[0];
|
|
1290
1426
|
if (!edge) {
|
|
1291
1427
|
throw new IntelError(400, "flow_branch_invalid", "A valid branch is required");
|
|
1292
1428
|
}
|
|
1293
|
-
|
|
1429
|
+
// ⚠️ The end is reached, never carried out (D25). It marks where the flow stops and holds
|
|
1430
|
+
// nothing to do, so handing it out as a step would ask the agent to perform an empty node
|
|
1431
|
+
// and then wait for the answer. The run ends here instead, with what the step that just
|
|
1432
|
+
// finished handed in — which is what "the result of a run" now means.
|
|
1433
|
+
if (nodeFor(version, edge.target)?.kind === "output") {
|
|
1434
|
+
status = "completed";
|
|
1435
|
+
output = stepOutput;
|
|
1436
|
+
completedAt = occurredAt;
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
currentNodeId = edge.target;
|
|
1440
|
+
}
|
|
1294
1441
|
}
|
|
1295
1442
|
const next = {
|
|
1296
1443
|
...current,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
|
|
1
|
+
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, FlowValidation, FlowVersion, KnowledgeNode, ListFlowRunsInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, ResourceVerb, SaveFlowVersionInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
|
|
2
2
|
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
3
3
|
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
4
4
|
export interface FlowRunChainEntry {
|
|
@@ -37,7 +37,6 @@ export interface FlowActor {
|
|
|
37
37
|
id: string;
|
|
38
38
|
email: string;
|
|
39
39
|
canRun: boolean;
|
|
40
|
-
canApprove: boolean;
|
|
41
40
|
isAdmin?: boolean;
|
|
42
41
|
}
|
|
43
42
|
export type FlowVerb = Extract<ResourceVerb, "read" | "write" | "execute">;
|
|
@@ -163,6 +162,7 @@ export interface FlowService {
|
|
|
163
162
|
listCalls(actor: FlowActor, flowId: string): Promise<{
|
|
164
163
|
items: Flow[];
|
|
165
164
|
}>;
|
|
165
|
+
validate(actor: FlowActor, flowId: string): Promise<FlowValidation>;
|
|
166
166
|
relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
|
|
167
167
|
listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
|
|
168
168
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
@@ -180,3 +180,8 @@ export interface CompiledFlow {
|
|
|
180
180
|
graph: FlowGraph;
|
|
181
181
|
triggerId: string;
|
|
182
182
|
}
|
|
183
|
+
export interface RunProblem {
|
|
184
|
+
status: number;
|
|
185
|
+
code: string;
|
|
186
|
+
detail: string;
|
|
187
|
+
}
|
package/dist/http/http.js
CHANGED
|
@@ -48,7 +48,6 @@ function asFlowActor(authorization) {
|
|
|
48
48
|
id: authorization.identity.id,
|
|
49
49
|
email: authorization.identity.email,
|
|
50
50
|
canRun: permits(authorization, "flows", "run"),
|
|
51
|
-
canApprove: permits(authorization, "flows", "approve"),
|
|
52
51
|
isAdmin: authorization.can("intel", "admin"),
|
|
53
52
|
};
|
|
54
53
|
}
|
|
@@ -283,6 +282,15 @@ export function createHttp(deps) {
|
|
|
283
282
|
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
284
283
|
return context.json(await deps.flows.listCalls(asFlowActor(auth), input.flowId));
|
|
285
284
|
});
|
|
285
|
+
// Would this flow start, for whoever is asking, right now (#72). `flows/read` and nothing more:
|
|
286
|
+
// the answer somebody without `execute` needs most is that they are missing `execute`, and it
|
|
287
|
+
// names nothing they could not read out of the graph. A GET because it changes nothing — no run,
|
|
288
|
+
// no key, no audit event.
|
|
289
|
+
app.get("/flows/:flowId/validation", async (context) => {
|
|
290
|
+
const auth = requireCapability(context, "flows", "read");
|
|
291
|
+
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
292
|
+
return context.json(await deps.flows.validate(asFlowActor(auth), input.flowId));
|
|
293
|
+
});
|
|
286
294
|
// What this flow needs: the documents and tools its graph names. `flows/read` and nothing more —
|
|
287
295
|
// it describes a flow the caller may already open, and names only what they may already see.
|
|
288
296
|
app.get("/flows/:flowId/requirements", async (context) => {
|
|
@@ -323,7 +323,6 @@ export function createKnowledge(deps) {
|
|
|
323
323
|
kind: input.kind,
|
|
324
324
|
title: input.title,
|
|
325
325
|
description: input.description,
|
|
326
|
-
contextPolicy: input.contextPolicy,
|
|
327
326
|
ownerId: actor.id,
|
|
328
327
|
currentVersionId: null,
|
|
329
328
|
createdAt: timestamp,
|
|
@@ -553,7 +552,6 @@ export function createKnowledge(deps) {
|
|
|
553
552
|
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
554
553
|
title: input.title ?? current.title,
|
|
555
554
|
description: input.description === undefined ? current.description : input.description,
|
|
556
|
-
contextPolicy: input.contextPolicy ?? current.contextPolicy,
|
|
557
555
|
updatedAt,
|
|
558
556
|
},
|
|
559
557
|
baseUpdatedAt: input.baseUpdatedAt,
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -39,7 +39,6 @@ export async function handleMcp(request, deps) {
|
|
|
39
39
|
id: deps.authorization.identity.id,
|
|
40
40
|
email: deps.authorization.identity.email,
|
|
41
41
|
canRun: permits(deps.authorization, "flows", "run"),
|
|
42
|
-
canApprove: permits(deps.authorization, "flows", "approve"),
|
|
43
42
|
isAdmin: deps.authorization.can("intel", "admin"),
|
|
44
43
|
};
|
|
45
44
|
const server = new McpServer({ name: "intel", version: "0.1.0" });
|
|
@@ -381,6 +380,18 @@ export async function handleMcp(request, deps) {
|
|
|
381
380
|
openWorldHint: false,
|
|
382
381
|
},
|
|
383
382
|
}, async (input) => text(await deps.flows.relationGraph(flowActor, input)));
|
|
383
|
+
server.registerTool("flow_validate", {
|
|
384
|
+
title: "Check whether a flow would start",
|
|
385
|
+
description: "Check whether this flow would start for the calling user right now, and name every reason it would not. Answers rather than acts: no run is created. The answer is a snapshot for this user at this moment, because tool reachability is a live query with their own portal token.",
|
|
386
|
+
inputSchema: GetFlowInput,
|
|
387
|
+
annotations: {
|
|
388
|
+
title: "Check whether a flow would start",
|
|
389
|
+
readOnlyHint: true,
|
|
390
|
+
destructiveHint: false,
|
|
391
|
+
idempotentHint: true,
|
|
392
|
+
openWorldHint: false,
|
|
393
|
+
},
|
|
394
|
+
}, async (input) => text(await deps.flows.validate(flowActor, input.flowId)));
|
|
384
395
|
server.registerTool("flow_requirements_list", {
|
|
385
396
|
title: "List what a flow needs",
|
|
386
397
|
description: "List the Knowledge documents and MCP tools one flow's graph names. Documents the calling user cannot see are counted rather than named, and no claim is made about whether anyone may reach them: for tools that cannot be known in advance, because the catalog is a live query with each user's own portal token.",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- #73. The approval node leaves the contract, so the graphs already stored are rewritten to match
|
|
2
|
+
-- the schema that will read them. Without this an `approval` node makes `FlowGraph.parse` throw in
|
|
3
|
+
-- `db-flows.ts`, and the flow cannot be opened at all — not even to repair it. A removal that locks
|
|
4
|
+
-- people out of their own flows is worse than the node it removes.
|
|
5
|
+
--
|
|
6
|
+
-- ⚠️ Rewritten to a `condition`, not deleted. The node has two outgoing branches, and deleting it
|
|
7
|
+
-- would leave two edges pointing nowhere and a graph that fails `compileFlow` for a second reason.
|
|
8
|
+
-- A condition is the honest replacement: it branches the same way, and the question the approval
|
|
9
|
+
-- asked — "may this go ahead?" — is one the agent carrying out the run can answer. The prompt is
|
|
10
|
+
-- carried over into the instruction rather than dropped, so the author still reads what was meant.
|
|
11
|
+
-- `timeout` disappears with the waiting it configured (D24).
|
|
12
|
+
--
|
|
13
|
+
-- ⚠️ The branch handles are rewritten with it: an approval branched on `approved`/`rejected`, a
|
|
14
|
+
-- condition on `yes`/`no`. Left alone, `completeStep` would look for a handle that no edge carries
|
|
15
|
+
-- and every run through that node would answer 400.
|
|
16
|
+
--
|
|
17
|
+
-- ⚠️ One pass over the arrays rather than targeted updates, for the same reason as 0005: re-running
|
|
18
|
+
-- must change nothing. A graph with no approval node is written back identical to itself.
|
|
19
|
+
UPDATE flow_versions
|
|
20
|
+
SET graph_json = json_set(
|
|
21
|
+
json_set(
|
|
22
|
+
graph_json,
|
|
23
|
+
'$.nodes',
|
|
24
|
+
(
|
|
25
|
+
SELECT json_group_array(
|
|
26
|
+
CASE
|
|
27
|
+
WHEN json_extract(node.value, '$.kind') = 'approval'
|
|
28
|
+
THEN json_set(
|
|
29
|
+
json_set(node.value, '$.kind', 'condition'),
|
|
30
|
+
'$.configuration',
|
|
31
|
+
json_object(
|
|
32
|
+
'mode', 'semantic',
|
|
33
|
+
'instruction', json_extract(node.value, '$.configuration.prompt')
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
ELSE node.value
|
|
37
|
+
END
|
|
38
|
+
)
|
|
39
|
+
FROM json_each(flow_versions.graph_json, '$.nodes') AS node
|
|
40
|
+
)
|
|
41
|
+
),
|
|
42
|
+
'$.edges',
|
|
43
|
+
(
|
|
44
|
+
SELECT json_group_array(
|
|
45
|
+
CASE json_extract(edge.value, '$.sourceHandle')
|
|
46
|
+
WHEN 'approved' THEN json_set(edge.value, '$.sourceHandle', 'yes')
|
|
47
|
+
WHEN 'rejected' THEN json_set(edge.value, '$.sourceHandle', 'no')
|
|
48
|
+
ELSE edge.value
|
|
49
|
+
END
|
|
50
|
+
)
|
|
51
|
+
FROM json_each(flow_versions.graph_json, '$.edges') AS edge
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
WHERE EXISTS (
|
|
55
|
+
SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
|
|
56
|
+
WHERE json_extract(node.value, '$.kind') = 'approval'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
-- `waiting` was written for the approval node and never set by anything: the two places that tested
|
|
60
|
+
-- for it only ever saw `running`. It leaves `FlowRunStatus`, so a row carrying it would become a
|
|
61
|
+
-- status the contract cannot parse. There should be none — this makes that true rather than hoped.
|
|
62
|
+
--
|
|
63
|
+
-- The column's CHECK constraint still allows the value, deliberately: changing it means rebuilding
|
|
64
|
+
-- the table in D1 for a value nothing writes.
|
|
65
|
+
UPDATE flow_runs
|
|
66
|
+
SET status = 'failed',
|
|
67
|
+
error = 'Flow run was waiting for an approval that no longer exists',
|
|
68
|
+
completed_at = COALESCE(completed_at, updated_at)
|
|
69
|
+
WHERE status = 'waiting';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
-- #74. Three kinds change shape at once, so the stored graphs are rewritten before the schema that
|
|
2
|
+
-- reads them changes. Without this they stop parsing in `db-flows.ts` and the flows become
|
|
3
|
+
-- unopenable — the trap 0007 was written for, at a larger scale.
|
|
4
|
+
--
|
|
5
|
+
-- ⚠️ A `knowledge` node was BOTH a step and its material. Under D25 those are two layers, so it
|
|
6
|
+
-- becomes two nodes: the step stays exactly where it stood in the chain and turns into an
|
|
7
|
+
-- `instruction`, and each reference it held becomes a link hanging off it on a context edge.
|
|
8
|
+
--
|
|
9
|
+
-- ▶ ── knowledge[a,b] ── ▶ becomes ▶ ── instruction ── ▶
|
|
10
|
+
-- │ │
|
|
11
|
+
-- link a link b
|
|
12
|
+
--
|
|
13
|
+
-- Splitting this way rather than turning the node itself into a link is what keeps every existing
|
|
14
|
+
-- edge valid: nothing is re-pointed, nothing is deleted, and a knowledge node sitting directly
|
|
15
|
+
-- after the start still works — as a link it would have had to attach to the start, which is a
|
|
16
|
+
-- marker and holds nothing. It is also the honest reading. The old node WAS a step; what it lacked
|
|
17
|
+
-- was anything saying what to do with the material, which is exactly what the instruction now says.
|
|
18
|
+
--
|
|
19
|
+
-- ⚠️ The kind a reference points at was never stored in the graph — the node only held ids. The
|
|
20
|
+
-- knowledge tree knows, so the rewrite reads it there. A reference whose target is gone becomes
|
|
21
|
+
-- `document`, which is what the old node treated everything as.
|
|
22
|
+
|
|
23
|
+
-- ── The edges: every old edge, plus one context edge per reference ───────────────────────────────
|
|
24
|
+
-- ⚠️ Runs BEFORE the node rewrite, and that order is the whole reason it is readable: while the
|
|
25
|
+
-- knowledge nodes are still knowledge nodes, the id of the step and the list of references sit in
|
|
26
|
+
-- the same row, so the new edge can name both without taking anybody's id apart.
|
|
27
|
+
UPDATE flow_versions
|
|
28
|
+
SET graph_json = json_set(
|
|
29
|
+
graph_json,
|
|
30
|
+
'$.edges',
|
|
31
|
+
(
|
|
32
|
+
SELECT json_group_array(json(value)) FROM (
|
|
33
|
+
SELECT edge.key AS position, 0 AS tier, edge.value AS value
|
|
34
|
+
FROM json_each(flow_versions.graph_json, '$.edges') AS edge
|
|
35
|
+
|
|
36
|
+
UNION ALL
|
|
37
|
+
|
|
38
|
+
SELECT 1000000 + node.key AS position, reference.key + 1 AS tier,
|
|
39
|
+
json_object(
|
|
40
|
+
'id', json_extract(node.value, '$.id') || '-link-' || reference.key || '-context',
|
|
41
|
+
'source', json_extract(node.value, '$.id'),
|
|
42
|
+
'target', json_extract(node.value, '$.id') || '-link-' || reference.key,
|
|
43
|
+
'kind', 'context',
|
|
44
|
+
'label', NULL,
|
|
45
|
+
'sourceHandle', NULL
|
|
46
|
+
) AS value
|
|
47
|
+
FROM json_each(flow_versions.graph_json, '$.nodes') AS node,
|
|
48
|
+
json_each(json_extract(node.value, '$.configuration.resourceIds')) AS reference
|
|
49
|
+
WHERE json_extract(node.value, '$.kind') = 'knowledge'
|
|
50
|
+
ORDER BY position, tier
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
WHERE EXISTS (
|
|
55
|
+
SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
|
|
56
|
+
WHERE json_extract(node.value, '$.kind') = 'knowledge'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
-- ── The nodes: every old node, plus one link per reference ───────────────────────────────────────
|
|
60
|
+
UPDATE flow_versions
|
|
61
|
+
SET graph_json = json_set(
|
|
62
|
+
graph_json,
|
|
63
|
+
'$.nodes',
|
|
64
|
+
(
|
|
65
|
+
SELECT json_group_array(json(value)) FROM (
|
|
66
|
+
-- The old nodes, in place and in order. A knowledge node becomes the instruction it always
|
|
67
|
+
-- half was; its retrieval hint becomes the prompt, because that is the only sentence the
|
|
68
|
+
-- author ever wrote about what should happen with the material.
|
|
69
|
+
SELECT node.key AS position, 0 AS tier,
|
|
70
|
+
CASE
|
|
71
|
+
WHEN json_extract(node.value, '$.kind') = 'knowledge'
|
|
72
|
+
THEN json_set(
|
|
73
|
+
json_set(node.value, '$.kind', 'instruction'),
|
|
74
|
+
'$.configuration',
|
|
75
|
+
json_object(
|
|
76
|
+
'prompt',
|
|
77
|
+
COALESCE(
|
|
78
|
+
NULLIF(json_extract(node.value, '$.configuration.query'), ''),
|
|
79
|
+
'Work with the attached material.'
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
-- The end marks and no longer makes: `template` was never read by anything.
|
|
84
|
+
WHEN json_extract(node.value, '$.kind') = 'output'
|
|
85
|
+
THEN json_set(node.value, '$.configuration', json_object())
|
|
86
|
+
ELSE node.value
|
|
87
|
+
END AS value
|
|
88
|
+
FROM json_each(flow_versions.graph_json, '$.nodes') AS node
|
|
89
|
+
|
|
90
|
+
UNION ALL
|
|
91
|
+
|
|
92
|
+
-- One link per reference, laid out below its step so the graph opens readable rather than
|
|
93
|
+
-- stacked. The id is derived from the node it came from, which keeps the rewrite idempotent:
|
|
94
|
+
-- running it twice produces the same ids, and there is no second knowledge node to expand.
|
|
95
|
+
SELECT node.key AS position, 1 AS tier,
|
|
96
|
+
json_object(
|
|
97
|
+
'id', json_extract(node.value, '$.id') || '-link-' || reference.key,
|
|
98
|
+
'kind', COALESCE(
|
|
99
|
+
(
|
|
100
|
+
SELECT CASE knowledge_nodes.kind
|
|
101
|
+
WHEN 'folder' THEN 'folder'
|
|
102
|
+
WHEN 'attachment' THEN 'upload'
|
|
103
|
+
WHEN 'table' THEN 'table'
|
|
104
|
+
ELSE 'document'
|
|
105
|
+
END
|
|
106
|
+
FROM knowledge_nodes WHERE knowledge_nodes.id = reference.value
|
|
107
|
+
),
|
|
108
|
+
'document'
|
|
109
|
+
),
|
|
110
|
+
'label', COALESCE(
|
|
111
|
+
(SELECT knowledge_nodes.title FROM knowledge_nodes WHERE knowledge_nodes.id = reference.value),
|
|
112
|
+
'Missing reference'
|
|
113
|
+
),
|
|
114
|
+
'position', json_object(
|
|
115
|
+
'x', json_extract(node.value, '$.position.x') + (reference.key * 200),
|
|
116
|
+
'y', json_extract(node.value, '$.position.y') + 160
|
|
117
|
+
),
|
|
118
|
+
'configuration', json_object('resourceId', reference.value)
|
|
119
|
+
) AS value
|
|
120
|
+
FROM json_each(flow_versions.graph_json, '$.nodes') AS node,
|
|
121
|
+
json_each(json_extract(node.value, '$.configuration.resourceIds')) AS reference
|
|
122
|
+
WHERE json_extract(node.value, '$.kind') = 'knowledge'
|
|
123
|
+
ORDER BY position, tier
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
WHERE EXISTS (
|
|
128
|
+
SELECT 1 FROM json_each(flow_versions.graph_json, '$.nodes') AS node
|
|
129
|
+
WHERE json_extract(node.value, '$.kind') IN ('knowledge', 'output')
|
|
130
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- #76. `context_policy` leaves the contract, the UI and every MCP answer. The COLUMN stays.
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ That is not the intent, it is what D1 permits. SQLite cannot drop a column a CHECK names, and
|
|
4
|
+
-- this one names itself. The way around it is a table rebuild, and `knowledge_nodes` carries six
|
|
5
|
+
-- foreign keys, one of them from itself.
|
|
6
|
+
--
|
|
7
|
+
-- Three attempts against the real database, three failures, each with its own lesson:
|
|
8
|
+
--
|
|
9
|
+
-- 1. `PRAGMA foreign_keys = OFF` — **ignored** by D1 over its HTTP API. It works locally, because
|
|
10
|
+
-- miniflare honours it, so the integration test was green while production answered
|
|
11
|
+
-- `FOREIGN KEY constraint failed` on the DROP. ⚠️ A green migration test does not prove a D1
|
|
12
|
+
-- migration runs.
|
|
13
|
+
-- 2. `PRAGMA defer_foreign_keys = true` — it works, but the self-reference was written as
|
|
14
|
+
-- `REFERENCES knowledge_nodes(id)` and therefore pointed at the table this same migration was
|
|
15
|
+
-- about to drop. SQLite rewrites a self-reference along with the rename, so the temporary name
|
|
16
|
+
-- belongs there.
|
|
17
|
+
-- 3. Self-reference fixed → green through `wrangler d1 migrations apply --local`, still red
|
|
18
|
+
-- against `--remote`. The reason is the execution model: D1 commits the statements of a
|
|
19
|
+
-- migration file one at a time, and the deferral only lasts until the next COMMIT. After the
|
|
20
|
+
-- first statement the reprieve is gone and `DROP TABLE` runs unprotected.
|
|
21
|
+
--
|
|
22
|
+
-- A table rebuild with foreign keys is therefore not possible inside a migration file. It needs a
|
|
23
|
+
-- session that drives the transaction itself.
|
|
24
|
+
--
|
|
25
|
+
-- What holds instead: the column sits in D1, nothing reads it, and `db.ts` writes a fixed value on
|
|
26
|
+
-- insert because it is NOT NULL without a DEFAULT. The contract does not know it — for every
|
|
27
|
+
-- consumer it is gone. What remains is one dead column, and that is the price of Intel running.
|
|
28
|
+
--
|
|
29
|
+
-- How it disappears after all is anchrd/intel#86.
|
|
30
|
+
--
|
|
31
|
+
-- This file deliberately does nothing. It is where the above is written down; deleted, it would be
|
|
32
|
+
-- a gap in the numbering that nobody explains.
|
|
33
|
+
SELECT 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.3.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
48
48
|
"ajv": "^8.20.0",
|
|
49
49
|
"hono": "^4.12.32",
|