@anchrd/intel-api 0.3.3 → 0.5.0
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/README.md +12 -2
- package/dist/adapters/db/db.js +8 -10
- package/dist/adapters/openid/openid.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.js +3 -0
- package/dist/auth/auth.js +45 -5
- package/dist/auth/auth.types.d.ts +2 -0
- 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/intel/intel.js +3 -1
- package/dist/knowledge/knowledge.js +0 -2
- package/dist/mcp/mcp.js +12 -1
- package/dist/tools/tools.js +12 -8
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +51 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -72,8 +72,18 @@ uses PKCE and dynamic public-client registration; browser access tokens stay ins
|
|
|
72
72
|
`node_modules/@anchrd/intel-api/examples/dev.vars.example` to `.dev.vars` for a local customer Worker.
|
|
73
73
|
|
|
74
74
|
The portal endpoint exposes RFC 9728 metadata. The Tools UI follows that metadata, dynamically
|
|
75
|
-
registers with Gate or Cloudflare Access, and completes a separate PKCE flow
|
|
76
|
-
|
|
75
|
+
registers with Gate or Cloudflare Access, and completes a separate PKCE flow — silently, with
|
|
76
|
+
`prompt=none`, as soon as a Gate session exists. Nobody is asked to connect anything.
|
|
77
|
+
|
|
78
|
+
⚠️ **Two settings outside this repository decide whether that silent sign-in can work.** In
|
|
79
|
+
Cloudflare Zero Trust → Access controls → AI controls → your portal → Edit → Advanced settings,
|
|
80
|
+
`Managed OAuth` must be enabled, and an Access policy must carry the people who use Intel. Without
|
|
81
|
+
both, every silent sign-in is refused and the Tools area shows "No access to the company portal" —
|
|
82
|
+
correct behaviour for somebody outside every policy, and a misleading one for a deployment that
|
|
83
|
+
simply never enabled the setting.
|
|
84
|
+
|
|
85
|
+
The only tool secret Intel stores is the resulting per-user access token for its own portal
|
|
86
|
+
endpoint — one per person, never one shared operator token — sealed with a key
|
|
77
87
|
derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of your D1; provider
|
|
78
88
|
credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
|
|
79
89
|
to another OAuth resource.
|
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,
|
|
@@ -153,10 +152,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
153
152
|
await deps.db.batch([
|
|
154
153
|
deps.db
|
|
155
154
|
.prepare(`INSERT INTO knowledge_nodes (
|
|
156
|
-
id, parent_id, kind, title, description,
|
|
155
|
+
id, parent_id, kind, title, description, owner_id,
|
|
157
156
|
current_version_id, created_at, updated_at, archived_at
|
|
158
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
159
|
-
.bind(node.id, node.parentId, node.kind, node.title, node.description, node.
|
|
157
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
158
|
+
.bind(node.id, node.parentId, node.kind, node.title, node.description, node.ownerId, node.currentVersionId, node.createdAt, node.updatedAt, node.archivedAt),
|
|
160
159
|
deps.db
|
|
161
160
|
.prepare(`INSERT INTO idempotency_keys (
|
|
162
161
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -239,10 +238,10 @@ export function createKnowledgeRepository(deps) {
|
|
|
239
238
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
240
239
|
)
|
|
241
240
|
UPDATE knowledge_nodes
|
|
242
|
-
SET parent_id = ?, title = ?, description = ?,
|
|
241
|
+
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
243
242
|
WHERE id = ? AND updated_at = ?
|
|
244
243
|
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))`)
|
|
245
|
-
.bind(node.id, node.parentId, node.title, node.description, node.
|
|
244
|
+
.bind(node.id, node.parentId, node.title, node.description, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId),
|
|
246
245
|
deps.db
|
|
247
246
|
.prepare(`INSERT INTO idempotency_keys (
|
|
248
247
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -250,9 +249,9 @@ export function createKnowledgeRepository(deps) {
|
|
|
250
249
|
WHERE EXISTS (
|
|
251
250
|
SELECT 1 FROM knowledge_nodes
|
|
252
251
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
253
|
-
AND
|
|
252
|
+
AND updated_at = ?
|
|
254
253
|
)`)
|
|
255
|
-
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.
|
|
254
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
|
|
256
255
|
deps.db
|
|
257
256
|
.prepare(`INSERT INTO audit_events (
|
|
258
257
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -265,7 +264,6 @@ export function createKnowledgeRepository(deps) {
|
|
|
265
264
|
.bind(input.auditId, input.actorId, node.id, JSON.stringify({
|
|
266
265
|
parentId: node.parentId,
|
|
267
266
|
title: node.title,
|
|
268
|
-
contextPolicy: node.contextPolicy,
|
|
269
267
|
}), node.updatedAt, input.actorId, input.idempotencyKey, node.id),
|
|
270
268
|
]);
|
|
271
269
|
}
|
|
@@ -27,6 +27,9 @@ const ConnectionPendingSession = z.strictObject({
|
|
|
27
27
|
resource: z.url(),
|
|
28
28
|
userId: z.string().min(1),
|
|
29
29
|
user: UserSession,
|
|
30
|
+
// Older cookies carry no `silent`, and one in flight across a deploy must not become an invalid
|
|
31
|
+
// session: absent means the visible flow, which is what those attempts were.
|
|
32
|
+
silent: z.boolean().default(false),
|
|
30
33
|
expiresAt: z.number().int().positive(),
|
|
31
34
|
});
|
|
32
35
|
const Session = z.discriminatedUnion("kind", [
|
package/dist/auth/auth.js
CHANGED
|
@@ -2,6 +2,22 @@ import { calculatePKCECodeChallenge, randomPKCECodeVerifier, randomState } from
|
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
3
|
import { SafeReturnPath } from "../shared/safe-return-path/safe-return-path.js";
|
|
4
4
|
const cookieName = "intel_session";
|
|
5
|
+
// What an authorization server answers when `prompt=none` would have worked, but only with somebody
|
|
6
|
+
// looking at a screen. It is the expected answer to a silent attempt, not a broken deployment.
|
|
7
|
+
const InteractionRequired = new Set([
|
|
8
|
+
"login_required",
|
|
9
|
+
"interaction_required",
|
|
10
|
+
"consent_required",
|
|
11
|
+
"account_selection_required",
|
|
12
|
+
]);
|
|
13
|
+
// One fixed marker, never the portal's own words. `error_description` is a sentence written by the
|
|
14
|
+
// authorization server about somebody else's policy: putting it in the URL would hand whatever
|
|
15
|
+
// renders the page next a string Intel does not control.
|
|
16
|
+
function withConnectError(returnTo) {
|
|
17
|
+
const url = new URL(returnTo, "https://intel.invalid");
|
|
18
|
+
url.searchParams.set("connectError", "portal_sign_in_refused");
|
|
19
|
+
return `${url.pathname}${url.search}`;
|
|
20
|
+
}
|
|
5
21
|
function cookieValue(headers) {
|
|
6
22
|
const values = headers.get("cookie")?.split(";") ?? [];
|
|
7
23
|
for (const value of values) {
|
|
@@ -51,6 +67,15 @@ export function createBrowserAuth(deps) {
|
|
|
51
67
|
createdAt: deps.now().toISOString(),
|
|
52
68
|
});
|
|
53
69
|
}
|
|
70
|
+
// The connection handoff parks the Intel session inside the pending cookie, so every way out of
|
|
71
|
+
// the callback has to put it back — the portal attempt must never cost somebody their Intel login.
|
|
72
|
+
async function restore(user, returnTo, location) {
|
|
73
|
+
const remaining = (user.expiresAt - deps.now().getTime()) / 1_000;
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
return redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`, sessionCookie("", secure, 0));
|
|
76
|
+
}
|
|
77
|
+
return redirect(location, sessionCookie(await deps.sessions.seal(user), secure, remaining));
|
|
78
|
+
}
|
|
54
79
|
async function userSession(client, tokens) {
|
|
55
80
|
return {
|
|
56
81
|
version: 1,
|
|
@@ -93,6 +118,10 @@ export function createBrowserAuth(deps) {
|
|
|
93
118
|
throw new IntelError(401, "authentication_required", "Authentication is required");
|
|
94
119
|
}
|
|
95
120
|
const returnTo = SafeReturnPath.catch("/tools").parse(requestUrl.searchParams.get("returnTo") ?? "/tools");
|
|
121
|
+
// Gate is the OIDC provider Cloudflare Access consumes, so whoever holds a valid Intel
|
|
122
|
+
// session is already the person the portal would ask about. `prompt=none` says exactly that:
|
|
123
|
+
// answer from the session that exists, and refuse rather than show anybody a login (#60).
|
|
124
|
+
const silent = requestUrl.searchParams.get("silent") === "1";
|
|
96
125
|
if (!deps.portalUrl) {
|
|
97
126
|
throw new IntelError(503, "portal_not_configured", "No MCP portal is configured for this deployment");
|
|
98
127
|
}
|
|
@@ -113,6 +142,7 @@ export function createBrowserAuth(deps) {
|
|
|
113
142
|
resource: discovered.resource,
|
|
114
143
|
userId,
|
|
115
144
|
user,
|
|
145
|
+
silent,
|
|
116
146
|
expiresAt: deps.now().getTime() + 10 * 60 * 1_000,
|
|
117
147
|
};
|
|
118
148
|
const authorizationUrl = await deps.oauth.authorizationUrl({
|
|
@@ -123,6 +153,7 @@ export function createBrowserAuth(deps) {
|
|
|
123
153
|
codeChallenge,
|
|
124
154
|
state,
|
|
125
155
|
...(discovered.scope ? { scope: discovered.scope } : {}),
|
|
156
|
+
...(silent ? { prompt: "none" } : {}),
|
|
126
157
|
});
|
|
127
158
|
return redirect(authorizationUrl.href, sessionCookie(await deps.sessions.seal(pending), secure, 10 * 60));
|
|
128
159
|
},
|
|
@@ -133,6 +164,19 @@ export function createBrowserAuth(deps) {
|
|
|
133
164
|
pending.expiresAt <= deps.now().getTime()) {
|
|
134
165
|
throw new IntelError(400, "oauth_session_invalid", "OAuth session is missing or expired");
|
|
135
166
|
}
|
|
167
|
+
// ⚠️ An authorization server reports a refusal on the redirect URI, not by failing the token
|
|
168
|
+
// exchange. Reading it here is what keeps a refused silent attempt from surfacing as a raw
|
|
169
|
+
// OAuth error, and it is the only place that knows whether a visible attempt is still owed.
|
|
170
|
+
const refusal = requestUrl.searchParams.get("error");
|
|
171
|
+
if (pending.kind === "connection-pending" && refusal) {
|
|
172
|
+
// The silent attempt only asked whether the sign-in works without a screen. "Not without
|
|
173
|
+
// one" is an answer, so the visible flow runs once — and because that one is not silent, a
|
|
174
|
+
// second refusal ends in the message instead of a third attempt.
|
|
175
|
+
const visibleAttemptLeft = pending.silent && InteractionRequired.has(refusal);
|
|
176
|
+
return await restore(pending.user, pending.returnTo, visibleAttemptLeft
|
|
177
|
+
? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
|
|
178
|
+
: withConnectError(pending.returnTo));
|
|
179
|
+
}
|
|
136
180
|
const tokens = await deps.oauth.exchange({
|
|
137
181
|
issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
|
|
138
182
|
clientId: pending.clientId,
|
|
@@ -151,11 +195,7 @@ export function createBrowserAuth(deps) {
|
|
|
151
195
|
clientId: pending.clientId,
|
|
152
196
|
resource: pending.resource,
|
|
153
197
|
});
|
|
154
|
-
|
|
155
|
-
if (remaining <= 0) {
|
|
156
|
-
return redirect(`/auth/login?returnTo=${encodeURIComponent(pending.returnTo)}`, sessionCookie("", secure, 0));
|
|
157
|
-
}
|
|
158
|
-
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(pending.user), secure, remaining));
|
|
198
|
+
return await restore(pending.user, pending.returnTo, pending.returnTo);
|
|
159
199
|
}
|
|
160
200
|
const session = await userSession(pending.clientId, tokens);
|
|
161
201
|
return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
|
|
@@ -24,6 +24,7 @@ export interface OAuthPort {
|
|
|
24
24
|
codeChallenge: string;
|
|
25
25
|
state: string;
|
|
26
26
|
scope?: string;
|
|
27
|
+
prompt?: "none";
|
|
27
28
|
}): Promise<URL>;
|
|
28
29
|
exchange(input: {
|
|
29
30
|
issuer: string;
|
|
@@ -76,6 +77,7 @@ export type AuthSession = {
|
|
|
76
77
|
resource: string;
|
|
77
78
|
userId: string;
|
|
78
79
|
user: UserAuthSession;
|
|
80
|
+
silent: boolean;
|
|
79
81
|
expiresAt: number;
|
|
80
82
|
} | UserAuthSession;
|
|
81
83
|
export interface SessionCodec {
|
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) => {
|
package/dist/intel/intel.js
CHANGED
|
@@ -32,7 +32,9 @@ export function createIntel(deps) {
|
|
|
32
32
|
const browserAuth = deps.auth;
|
|
33
33
|
if (browserAuth) {
|
|
34
34
|
app.get("/auth/login", async (context) => await browserAuth.login(new URL(context.req.url)));
|
|
35
|
-
// One connect route for the one portal: there are no per-source connections any more.
|
|
35
|
+
// One connect route for the one portal: there are no per-source connections any more. With
|
|
36
|
+
// `?silent=1` it runs `prompt=none`, which is how the Tools screen reaches it without anybody
|
|
37
|
+
// clicking (#60); the route itself is unchanged otherwise, including who may use it.
|
|
36
38
|
app.get("/auth/connect", async (context) => {
|
|
37
39
|
const connectReturnTo = encodeURIComponent("/auth/connect?returnTo=/tools");
|
|
38
40
|
const session = await browserAuth.resolve(context.req.raw.headers);
|
|
@@ -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.",
|
package/dist/tools/tools.js
CHANGED
|
@@ -12,20 +12,23 @@ export function createTools(deps) {
|
|
|
12
12
|
return deps.portalUrl;
|
|
13
13
|
}
|
|
14
14
|
// Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
|
|
15
|
-
// this user have a usable portal token".
|
|
15
|
+
// this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
|
|
16
|
+
// operator token for everybody would make every catalog the same one and the portal's Access
|
|
17
|
+
// policies decorative (ADR-0003).
|
|
16
18
|
async function accessToken(actor) {
|
|
17
19
|
const stored = await deps.tokens.read(actor.id);
|
|
18
20
|
if (!stored) {
|
|
19
|
-
throw new IntelError(401, "portal_not_connected", "
|
|
21
|
+
throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
|
|
20
22
|
}
|
|
21
23
|
if (stored.expiresAt > deps.now().getTime() + RefreshWindowMs)
|
|
22
24
|
return stored.accessToken;
|
|
23
25
|
const refreshed = stored.refreshToken ? await deps.refresh(stored) : null;
|
|
24
26
|
if (!refreshed) {
|
|
25
27
|
// A token that cannot be renewed is dropped: leaving it would keep failing every call with a
|
|
26
|
-
// stale credential
|
|
28
|
+
// stale credential. The browser answers this by signing in silently again (#60); an MCP
|
|
29
|
+
// client sees the code and repeats its own authorization.
|
|
27
30
|
await deps.tokens.clear(actor.id);
|
|
28
|
-
throw new IntelError(401, "portal_reconnect_required", "
|
|
31
|
+
throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
|
|
29
32
|
}
|
|
30
33
|
await deps.tokens.write(actor.id, refreshed);
|
|
31
34
|
return refreshed.accessToken;
|
|
@@ -83,17 +86,18 @@ export function createTools(deps) {
|
|
|
83
86
|
return {
|
|
84
87
|
async catalog(actor) {
|
|
85
88
|
const stored = await deps.tokens.read(actor.id);
|
|
86
|
-
//
|
|
89
|
+
// No portal sign-in yet is a normal state, not an error: the browser answers it by running
|
|
90
|
+
// the silent sign-in and asking again (#60).
|
|
87
91
|
if (!stored)
|
|
88
92
|
return { portalConnected: false, items: [] };
|
|
89
93
|
try {
|
|
90
94
|
return { portalConnected: true, items: await capabilities(actor) };
|
|
91
95
|
}
|
|
92
96
|
catch (error) {
|
|
93
|
-
// A token that is gone or beyond renewal is the same answer as never having
|
|
97
|
+
// A token that is gone or beyond renewal is the same answer as never having signed in, so
|
|
94
98
|
// reading the catalog reports it as a state. Only a portal that does not answer stays an
|
|
95
|
-
// error — the view has to tell "
|
|
96
|
-
//
|
|
99
|
+
// error — the view has to tell "sign in again" apart from "the portal failed", and a 401
|
|
100
|
+
// here would otherwise look like an expired Intel session to the browser.
|
|
97
101
|
if (error instanceof IntelError &&
|
|
98
102
|
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
|
|
99
103
|
return { portalConnected: false, items: [] };
|
|
@@ -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,51 @@
|
|
|
1
|
+
-- #76. `context_policy` leaves the tree. It was written, stored and handed back, and never once
|
|
2
|
+
-- read to decide anything — and it could not have been: it instructed a retrieval Intel does not
|
|
3
|
+
-- perform. Intel hands out references and the agent fetches what it needs (D24).
|
|
4
|
+
--
|
|
5
|
+
-- ⚠️ SQLite cannot drop a column that a CHECK constraint names, and this one names itself. The
|
|
6
|
+
-- table has to be rebuilt, which for `knowledge_nodes` means six foreign keys pointing at it —
|
|
7
|
+
-- including one from itself. The order below is SQLite's own twelve-step recipe, and every line of
|
|
8
|
+
-- it is load-bearing:
|
|
9
|
+
--
|
|
10
|
+
-- * `PRAGMA foreign_keys` is turned off first. Without it the DROP below would either fail or
|
|
11
|
+
-- cascade into the rows of every referencing table.
|
|
12
|
+
-- * The new table is created under a different name and renamed afterwards, rather than the old
|
|
13
|
+
-- one being renamed out of the way. Renaming the OLD table would silently re-point every
|
|
14
|
+
-- foreign key in the other five tables at `knowledge_nodes_old` — SQLite updates references to
|
|
15
|
+
-- follow a rename, and that is exactly what must not happen here.
|
|
16
|
+
-- * The indexes are recreated, because dropping the table drops them with it.
|
|
17
|
+
-- * `PRAGMA foreign_key_check` is the proof, not the hope. The integration test asserts it comes
|
|
18
|
+
-- back empty.
|
|
19
|
+
PRAGMA foreign_keys = OFF;
|
|
20
|
+
|
|
21
|
+
CREATE TABLE knowledge_nodes_rebuilt (
|
|
22
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
23
|
+
parent_id TEXT REFERENCES knowledge_nodes(id),
|
|
24
|
+
kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table')),
|
|
25
|
+
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
|
|
26
|
+
description TEXT CHECK (description IS NULL OR length(description) <= 2000),
|
|
27
|
+
owner_id TEXT NOT NULL,
|
|
28
|
+
current_version_id TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
updated_at TEXT NOT NULL,
|
|
31
|
+
archived_at TEXT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
INSERT INTO knowledge_nodes_rebuilt (
|
|
35
|
+
id, parent_id, kind, title, description, owner_id,
|
|
36
|
+
current_version_id, created_at, updated_at, archived_at
|
|
37
|
+
)
|
|
38
|
+
SELECT id, parent_id, kind, title, description, owner_id,
|
|
39
|
+
current_version_id, created_at, updated_at, archived_at
|
|
40
|
+
FROM knowledge_nodes;
|
|
41
|
+
|
|
42
|
+
DROP TABLE knowledge_nodes;
|
|
43
|
+
|
|
44
|
+
ALTER TABLE knowledge_nodes_rebuilt RENAME TO knowledge_nodes;
|
|
45
|
+
|
|
46
|
+
CREATE INDEX knowledge_nodes_parent_idx ON knowledge_nodes(parent_id, archived_at, title);
|
|
47
|
+
CREATE INDEX knowledge_nodes_owner_idx ON knowledge_nodes(owner_id, archived_at);
|
|
48
|
+
|
|
49
|
+
PRAGMA foreign_key_check;
|
|
50
|
+
|
|
51
|
+
PRAGMA foreign_keys = ON;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|