@anchrd/intel-api 0.2.1 → 0.3.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/cloudflare/cloudflare.js +1 -0
- package/dist/adapters/db/db-flows.js +62 -7
- package/dist/cli/cli.js +4 -1
- package/dist/flows/flows.js +49 -2
- package/dist/flows/flows.types.d.ts +14 -4
- package/dist/http/http.js +33 -2
- package/dist/knowledge/knowledge.js +11 -0
- package/dist/knowledge/knowledge.types.d.ts +2 -0
- package/dist/mcp/mcp.js +16 -4
- package/dist/tools/tools.js +14 -1
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/package.json +1 -1
|
@@ -143,6 +143,7 @@ export default {
|
|
|
143
143
|
throw error;
|
|
144
144
|
}
|
|
145
145
|
},
|
|
146
|
+
folderAccess: async (actor, folderId) => await knowledge.folderAccess(actor, folderId),
|
|
146
147
|
toolFingerprint: async (actor, toolName) => {
|
|
147
148
|
const catalog = await tools
|
|
148
149
|
.catalog({ id: actor.id, email: actor.email, canExecute: true })
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
|
|
2
|
-
const flowColumns = `id, title, description, owner_id, current_version_id,
|
|
2
|
+
const flowColumns = `id, parent_id, title, description, owner_id, current_version_id,
|
|
3
3
|
published_version_id, created_at, updated_at, archived_at`;
|
|
4
4
|
const runColumnNames = [
|
|
5
5
|
"id",
|
|
@@ -39,6 +39,7 @@ function accessBindings(actor, now, roles = []) {
|
|
|
39
39
|
function mapFlow(row) {
|
|
40
40
|
return Flow.parse({
|
|
41
41
|
id: row.id,
|
|
42
|
+
parentId: row.parent_id,
|
|
42
43
|
title: row.title,
|
|
43
44
|
description: row.description,
|
|
44
45
|
ownerId: row.owner_id,
|
|
@@ -103,12 +104,20 @@ function mapGrant(row) {
|
|
|
103
104
|
export function createFlowRepository(deps) {
|
|
104
105
|
const visible = accessPredicate();
|
|
105
106
|
return {
|
|
106
|
-
async listVisible(actor) {
|
|
107
|
+
async listVisible(actor, input = {}) {
|
|
108
|
+
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared
|
|
109
|
+
// tree. `IS ?` would collapse the two, so the two cases are separate SQL rather than one
|
|
110
|
+
// binding that silently means both.
|
|
111
|
+
const scope = input.parentId === undefined
|
|
112
|
+
? { clause: "", bindings: [] }
|
|
113
|
+
: input.parentId === null
|
|
114
|
+
? { clause: "AND flow.parent_id IS NULL", bindings: [] }
|
|
115
|
+
: { clause: "AND flow.parent_id = ?", bindings: [input.parentId] };
|
|
107
116
|
const result = await deps.db
|
|
108
117
|
.prepare(`SELECT ${flowColumns} FROM flows flow
|
|
109
|
-
WHERE ${visible} AND flow.archived_at IS NULL
|
|
118
|
+
WHERE ${visible} AND flow.archived_at IS NULL ${scope.clause}
|
|
110
119
|
ORDER BY lower(flow.title), flow.id`)
|
|
111
|
-
.bind(...accessBindings(actor, deps.now().toISOString()))
|
|
120
|
+
.bind(...accessBindings(actor, deps.now().toISOString()), ...scope.bindings)
|
|
112
121
|
.all();
|
|
113
122
|
return (result.results ?? []).map(mapFlow);
|
|
114
123
|
},
|
|
@@ -159,10 +168,10 @@ export function createFlowRepository(deps) {
|
|
|
159
168
|
await deps.db.batch([
|
|
160
169
|
deps.db
|
|
161
170
|
.prepare(`INSERT INTO flows (
|
|
162
|
-
id, title, description, owner_id, current_version_id, published_version_id,
|
|
171
|
+
id, parent_id, title, description, owner_id, current_version_id, published_version_id,
|
|
163
172
|
created_at, updated_at, archived_at
|
|
164
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
165
|
-
.bind(flow.id, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
|
|
173
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
174
|
+
.bind(flow.id, flow.parentId, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
|
|
166
175
|
deps.db
|
|
167
176
|
.prepare(`INSERT INTO idempotency_keys (
|
|
168
177
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -189,6 +198,52 @@ export function createFlowRepository(deps) {
|
|
|
189
198
|
throw error;
|
|
190
199
|
}
|
|
191
200
|
},
|
|
201
|
+
// Renaming and moving touch the flow row and nothing else. `current_version_id` and
|
|
202
|
+
// `published_version_id` are deliberately absent from the SET list: organization must not be
|
|
203
|
+
// able to change what a flow does (ADR-0004).
|
|
204
|
+
async updateFlow(input) {
|
|
205
|
+
const flow = input.flow;
|
|
206
|
+
try {
|
|
207
|
+
await deps.db.batch([
|
|
208
|
+
deps.db
|
|
209
|
+
.prepare(`UPDATE flows SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
210
|
+
WHERE id = ? AND updated_at = ?`)
|
|
211
|
+
.bind(flow.parentId, flow.title, flow.description, flow.updatedAt, flow.id, input.baseUpdatedAt),
|
|
212
|
+
deps.db
|
|
213
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
214
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
215
|
+
) SELECT ?, 'flows.update', ?, ?, ?
|
|
216
|
+
WHERE EXISTS (
|
|
217
|
+
SELECT 1 FROM flows
|
|
218
|
+
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ? AND updated_at = ?
|
|
219
|
+
)`)
|
|
220
|
+
.bind(input.actorId, input.idempotencyKey, flow.id, flow.updatedAt, flow.id, flow.parentId, flow.title, flow.description, flow.updatedAt),
|
|
221
|
+
deps.db
|
|
222
|
+
.prepare(`INSERT INTO audit_events (
|
|
223
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
224
|
+
) SELECT ?, ?, 'flows.update', 'flow', ?, ?, ?
|
|
225
|
+
WHERE EXISTS (
|
|
226
|
+
SELECT 1 FROM idempotency_keys
|
|
227
|
+
WHERE actor_id = ? AND operation = 'flows.update'
|
|
228
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
229
|
+
)`)
|
|
230
|
+
.bind(input.auditId, input.actorId, flow.id, JSON.stringify({ parentId: flow.parentId, title: flow.title }), flow.updatedAt, input.actorId, input.idempotencyKey, flow.id),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (!(await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey))) {
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey);
|
|
239
|
+
if (!replayed)
|
|
240
|
+
return "conflict";
|
|
241
|
+
const row = await deps.db
|
|
242
|
+
.prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
|
|
243
|
+
.bind(replayed)
|
|
244
|
+
.first();
|
|
245
|
+
return row ? mapFlow(row) : "conflict";
|
|
246
|
+
},
|
|
192
247
|
async getVersion(versionId) {
|
|
193
248
|
const row = await deps.db
|
|
194
249
|
.prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
|
package/dist/cli/cli.js
CHANGED
|
@@ -8,7 +8,10 @@ const interfaces = [
|
|
|
8
8
|
functions: ["read", "create", "write", "publish", "run", "approve", "share"],
|
|
9
9
|
},
|
|
10
10
|
{ handle: "tools", functions: ["read", "test", "execute", "admin"] },
|
|
11
|
-
|
|
11
|
+
// `mcp:connect` heisst bei jedem MCP-Dienst dieser Installation gleich. Ein eigener Name fuer
|
|
12
|
+
// dieselbe Sache zwingt den Betreiber, pro Dienst nachzusehen, welches Recht den Portal-Zugang
|
|
13
|
+
// traegt.
|
|
14
|
+
{ handle: "mcp", functions: ["connect"] },
|
|
12
15
|
];
|
|
13
16
|
const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
|
|
14
17
|
intel prepare Copy versioned D1 migrations to .intel/migrations
|
package/dist/flows/flows.js
CHANGED
|
@@ -103,6 +103,23 @@ export function createFlows(deps) {
|
|
|
103
103
|
}
|
|
104
104
|
return flow;
|
|
105
105
|
}
|
|
106
|
+
// A flow's parent is a Knowledge folder, so the answer comes from Knowledge rather than from a
|
|
107
|
+
// second permission model here. `null` is the root and needs no permission of its own — the same
|
|
108
|
+
// as creating a folder at the root does.
|
|
109
|
+
async function requireFolder(actor, parentId) {
|
|
110
|
+
if (parentId === null)
|
|
111
|
+
return;
|
|
112
|
+
const access = await deps.folderAccess(actor, parentId);
|
|
113
|
+
if (access === "missing") {
|
|
114
|
+
throw new IntelError(404, "flow_parent_not_found", "Parent folder was not found");
|
|
115
|
+
}
|
|
116
|
+
if (access === "not-a-folder") {
|
|
117
|
+
throw new IntelError(409, "parent_not_folder", "A flow's parent must be a folder");
|
|
118
|
+
}
|
|
119
|
+
if (access === "forbidden") {
|
|
120
|
+
throw new IntelError(403, "knowledge_forbidden", "Destination folder cannot be edited");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
106
123
|
async function requireVersion(versionId, flowId) {
|
|
107
124
|
const version = await deps.repository.getVersion(versionId);
|
|
108
125
|
if (!version || (flowId && version.flowId !== flowId)) {
|
|
@@ -116,8 +133,8 @@ export function createFlows(deps) {
|
|
|
116
133
|
return { run, node: nodeFor(version, run.currentNodeId) };
|
|
117
134
|
}
|
|
118
135
|
return {
|
|
119
|
-
async list(actor) {
|
|
120
|
-
return { items: await deps.repository.listVisible(actor) };
|
|
136
|
+
async list(actor, input = {}) {
|
|
137
|
+
return { items: await deps.repository.listVisible(actor, input) };
|
|
121
138
|
},
|
|
122
139
|
async get(actor, flowId) {
|
|
123
140
|
const flow = await requireFlow(actor, flowId);
|
|
@@ -132,10 +149,12 @@ export function createFlows(deps) {
|
|
|
132
149
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
|
|
133
150
|
if (replayed)
|
|
134
151
|
return await requireFlow(actor, replayed);
|
|
152
|
+
await requireFolder(actor, input.parentId);
|
|
135
153
|
const occurredAt = deps.now().toISOString();
|
|
136
154
|
return await deps.repository.insertFlow({
|
|
137
155
|
flow: {
|
|
138
156
|
id: deps.id(),
|
|
157
|
+
parentId: input.parentId,
|
|
139
158
|
title: input.title,
|
|
140
159
|
description: input.description,
|
|
141
160
|
ownerId: actor.id,
|
|
@@ -150,6 +169,34 @@ export function createFlows(deps) {
|
|
|
150
169
|
auditId: deps.id(),
|
|
151
170
|
});
|
|
152
171
|
},
|
|
172
|
+
// Rename and move. It reads and writes the flow row alone: no version is appended, no published
|
|
173
|
+
// version is touched and no run is signalled, so tidying up the tree cannot change what a flow
|
|
174
|
+
// does (ADR-0004).
|
|
175
|
+
async update(actor, input) {
|
|
176
|
+
const current = await requireEdit(actor, input.flowId);
|
|
177
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.update", input.idempotencyKey);
|
|
178
|
+
if (replayed)
|
|
179
|
+
return await requireFlow(actor, replayed);
|
|
180
|
+
if (input.parentId !== undefined)
|
|
181
|
+
await requireFolder(actor, input.parentId);
|
|
182
|
+
const updated = await deps.repository.updateFlow({
|
|
183
|
+
flow: {
|
|
184
|
+
...current,
|
|
185
|
+
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
186
|
+
title: input.title ?? current.title,
|
|
187
|
+
description: input.description === undefined ? current.description : input.description,
|
|
188
|
+
updatedAt: deps.now().toISOString(),
|
|
189
|
+
},
|
|
190
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
191
|
+
actorId: actor.id,
|
|
192
|
+
idempotencyKey: input.idempotencyKey,
|
|
193
|
+
auditId: deps.id(),
|
|
194
|
+
});
|
|
195
|
+
if (updated === "conflict") {
|
|
196
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
197
|
+
}
|
|
198
|
+
return updated;
|
|
199
|
+
},
|
|
153
200
|
async save(actor, input) {
|
|
154
201
|
const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
|
|
155
202
|
if (replayed) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, PublishFlowInput, ResourceGrant, RevokeFlowGrantInput, SaveFlowVersionInput, ShareFlowInput, StartFlowRunInput } from "@anchrd/intel-contract";
|
|
1
|
+
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, ListFlowsInput, PublishFlowInput, ResourceGrant, RevokeFlowGrantInput, SaveFlowVersionInput, ShareFlowInput, StartFlowRunInput, UpdateFlowInput } from "@anchrd/intel-contract";
|
|
2
2
|
export interface FlowActor {
|
|
3
3
|
id: string;
|
|
4
4
|
email: string;
|
|
5
5
|
canRun: boolean;
|
|
6
6
|
canApprove: boolean;
|
|
7
7
|
}
|
|
8
|
-
export type FlowOperation = "flows.create" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
|
|
8
|
+
export type FlowOperation = "flows.create" | "flows.update" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
|
|
9
9
|
export interface FlowRepository {
|
|
10
|
-
listVisible(actor: FlowActor): Promise<Flow[]>;
|
|
10
|
+
listVisible(actor: FlowActor, input?: ListFlowsInput): Promise<Flow[]>;
|
|
11
11
|
getVisible(actor: FlowActor, flowId: string): Promise<Flow | null>;
|
|
12
12
|
canEdit(actor: FlowActor, flowId: string): Promise<boolean>;
|
|
13
13
|
canManage(actor: FlowActor, flowId: string): Promise<boolean>;
|
|
@@ -19,6 +19,13 @@ export interface FlowRepository {
|
|
|
19
19
|
idempotencyKey: string;
|
|
20
20
|
auditId: string;
|
|
21
21
|
}): Promise<Flow>;
|
|
22
|
+
updateFlow(input: {
|
|
23
|
+
flow: Flow;
|
|
24
|
+
baseUpdatedAt: string;
|
|
25
|
+
actorId: string;
|
|
26
|
+
idempotencyKey: string;
|
|
27
|
+
auditId: string;
|
|
28
|
+
}): Promise<"conflict" | Flow>;
|
|
22
29
|
getVersion(versionId: string): Promise<FlowVersion | null>;
|
|
23
30
|
insertVersion(input: {
|
|
24
31
|
version: FlowVersion;
|
|
@@ -80,15 +87,18 @@ export interface FlowDeps {
|
|
|
80
87
|
id(): string;
|
|
81
88
|
now(): Date;
|
|
82
89
|
knowledgeExists(actor: FlowActor, resourceId: string): Promise<boolean>;
|
|
90
|
+
folderAccess(actor: FlowActor, folderId: string): Promise<FolderAccess>;
|
|
83
91
|
toolFingerprint(actor: FlowActor, toolName: string): Promise<string | null>;
|
|
84
92
|
unavailableTools(actor: FlowActor, toolNames: string[]): Promise<string[]>;
|
|
85
93
|
}
|
|
94
|
+
export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
|
|
86
95
|
export interface FlowService {
|
|
87
|
-
list(actor: FlowActor): Promise<{
|
|
96
|
+
list(actor: FlowActor, input?: ListFlowsInput): Promise<{
|
|
88
97
|
items: Flow[];
|
|
89
98
|
}>;
|
|
90
99
|
get(actor: FlowActor, flowId: string): Promise<FlowDocument>;
|
|
91
100
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
101
|
+
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
92
102
|
save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
93
103
|
publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
|
|
94
104
|
start(actor: FlowActor, input: StartFlowRunInput): Promise<FlowRunStep>;
|
package/dist/http/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, KnowledgeGraphInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, KnowledgeGraphInput, ListFlowsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { authorizeBearer, bearer } from "../shared/gate-authorization/gate-authorization.js";
|
|
@@ -64,6 +64,16 @@ export function createHttp(deps) {
|
|
|
64
64
|
}
|
|
65
65
|
return authorization;
|
|
66
66
|
}
|
|
67
|
+
// The caller's own identity, so the shell can name who is signed in. It needs no capability —
|
|
68
|
+
// the answer is the token's own subject — and it returns nothing but id, email, and name.
|
|
69
|
+
app.get("/session", (context) => {
|
|
70
|
+
const { identity } = context.get("authorization");
|
|
71
|
+
return context.json({
|
|
72
|
+
id: identity.id,
|
|
73
|
+
email: identity.email,
|
|
74
|
+
name: identity.name ?? null,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
67
77
|
app.get("/knowledge", async (context) => {
|
|
68
78
|
const auth = requireCapability(context, "knowledge", "read");
|
|
69
79
|
const url = new URL(context.req.url);
|
|
@@ -200,7 +210,20 @@ export function createHttp(deps) {
|
|
|
200
210
|
});
|
|
201
211
|
app.get("/flows", async (context) => {
|
|
202
212
|
const auth = requireCapability(context, "flows", "read");
|
|
203
|
-
|
|
213
|
+
const url = new URL(context.req.url);
|
|
214
|
+
const unknownQuery = [];
|
|
215
|
+
url.searchParams.forEach((_value, key) => {
|
|
216
|
+
if (key !== "parentId")
|
|
217
|
+
unknownQuery.push(key);
|
|
218
|
+
});
|
|
219
|
+
if (unknownQuery.length) {
|
|
220
|
+
throw new IntelError(400, "invalid_request", `Unknown query parameters: ${unknownQuery.join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
// Absent means "every flow I may see"; present but empty means the root of the shared tree.
|
|
223
|
+
// Without that distinction a tree level and a full list would be the same request.
|
|
224
|
+
const parentId = url.searchParams.get("parentId");
|
|
225
|
+
const input = ListFlowsInput.parse(url.searchParams.has("parentId") ? { parentId: parentId === "" ? null : parentId } : {});
|
|
226
|
+
return context.json(await deps.flows.list(asFlowActor(auth), input));
|
|
204
227
|
});
|
|
205
228
|
app.get("/flows/:flowId", async (context) => {
|
|
206
229
|
const auth = requireCapability(context, "flows", "read");
|
|
@@ -232,6 +255,14 @@ export function createHttp(deps) {
|
|
|
232
255
|
const input = CreateFlowInput.parse(await context.req.json().catch(() => null));
|
|
233
256
|
return context.json(await deps.flows.create(asFlowActor(auth), input), 201);
|
|
234
257
|
});
|
|
258
|
+
app.patch("/flows/:flowId", async (context) => {
|
|
259
|
+
const auth = requireCapability(context, "flows", "write");
|
|
260
|
+
const input = UpdateFlowInput.parse(await context.req.json().catch(() => null));
|
|
261
|
+
if (input.flowId !== context.req.param("flowId")) {
|
|
262
|
+
throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
|
|
263
|
+
}
|
|
264
|
+
return context.json(await deps.flows.update(asFlowActor(auth), input));
|
|
265
|
+
});
|
|
235
266
|
app.post("/flows/:flowId/versions", async (context) => {
|
|
236
267
|
const auth = requireCapability(context, "flows", "write");
|
|
237
268
|
const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
|
|
@@ -97,6 +97,17 @@ export function createKnowledge(deps) {
|
|
|
97
97
|
async get(actor, nodeId) {
|
|
98
98
|
return await getDocument(await requireVisible(actor, nodeId));
|
|
99
99
|
},
|
|
100
|
+
// Knowledge and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
|
|
101
|
+
// it: may this actor file something in that folder. The answer stays here, with the tree and
|
|
102
|
+
// its ACLs, rather than being reimplemented on the flow side.
|
|
103
|
+
async folderAccess(actor, folderId) {
|
|
104
|
+
const folder = await deps.repository.getVisible(actor, folderId);
|
|
105
|
+
if (!folder || folder.archivedAt)
|
|
106
|
+
return "missing";
|
|
107
|
+
if (folder.kind !== "folder")
|
|
108
|
+
return "not-a-folder";
|
|
109
|
+
return (await deps.repository.canEdit(actor, folder.id)) ? "ok" : "forbidden";
|
|
110
|
+
},
|
|
100
111
|
async create(actor, input) {
|
|
101
112
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.create", input.idempotencyKey);
|
|
102
113
|
if (existingId)
|
|
@@ -108,11 +108,13 @@ export interface KnowledgeDeps {
|
|
|
108
108
|
};
|
|
109
109
|
semantic?: SemanticIndex | undefined;
|
|
110
110
|
}
|
|
111
|
+
export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
|
|
111
112
|
export interface KnowledgeService {
|
|
112
113
|
list(actor: Actor, input: ListKnowledgeNodesInput): Promise<{
|
|
113
114
|
items: KnowledgeNode[];
|
|
114
115
|
}>;
|
|
115
116
|
get(actor: Actor, nodeId: string): Promise<KnowledgeDocument>;
|
|
117
|
+
folderAccess(actor: Actor, folderId: string): Promise<FolderAccess>;
|
|
116
118
|
create(actor: Actor, input: CreateKnowledgeNodeInput): Promise<KnowledgeNode>;
|
|
117
119
|
save(actor: Actor, input: SaveKnowledgeVersionInput): Promise<KnowledgeDocument>;
|
|
118
120
|
saveAttachment(actor: Actor, input: SaveKnowledgeAttachmentInput): Promise<KnowledgeDocument>;
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, KnowledgeGraphInput, ListFlowGrantsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetKnowledgeNodeInput, KnowledgeGraphInput, ListFlowGrantsInput, ListFlowsInput, ListKnowledgeGrantsInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -295,8 +295,8 @@ export async function handleMcp(request, deps) {
|
|
|
295
295
|
if (deps.authorization.can("flows", "read")) {
|
|
296
296
|
server.registerTool("flows_list", {
|
|
297
297
|
title: "List flows",
|
|
298
|
-
description: "List authorized
|
|
299
|
-
inputSchema:
|
|
298
|
+
description: "List authorized flows, either all of them or the ones filed in one folder of the shared Knowledge tree.",
|
|
299
|
+
inputSchema: ListFlowsInput,
|
|
300
300
|
annotations: {
|
|
301
301
|
title: "List flows",
|
|
302
302
|
readOnlyHint: true,
|
|
@@ -304,7 +304,7 @@ export async function handleMcp(request, deps) {
|
|
|
304
304
|
idempotentHint: true,
|
|
305
305
|
openWorldHint: false,
|
|
306
306
|
},
|
|
307
|
-
}, async () => text(await deps.flows.list(flowActor)));
|
|
307
|
+
}, async (input) => text(await deps.flows.list(flowActor, input)));
|
|
308
308
|
server.registerTool("flow_get", {
|
|
309
309
|
title: "Get flow",
|
|
310
310
|
description: "Load one authorized flow and its current immutable graph on demand.",
|
|
@@ -383,6 +383,18 @@ export async function handleMcp(request, deps) {
|
|
|
383
383
|
}, async (input) => text(await deps.flows.create(flowActor, input)));
|
|
384
384
|
}
|
|
385
385
|
if (deps.authorization.can("flows", "write")) {
|
|
386
|
+
server.registerTool("flow_update", {
|
|
387
|
+
title: "Rename or move flow",
|
|
388
|
+
description: "Rename a flow, change its description, or move it to another folder of the shared Knowledge tree. Never changes what the flow does.",
|
|
389
|
+
inputSchema: UpdateFlowInput,
|
|
390
|
+
annotations: {
|
|
391
|
+
title: "Rename or move flow",
|
|
392
|
+
readOnlyHint: false,
|
|
393
|
+
destructiveHint: false,
|
|
394
|
+
idempotentHint: true,
|
|
395
|
+
openWorldHint: false,
|
|
396
|
+
},
|
|
397
|
+
}, async (input) => text(await deps.flows.update(flowActor, input)));
|
|
386
398
|
server.registerTool("flow_save", {
|
|
387
399
|
title: "Save flow",
|
|
388
400
|
description: "Append a validated immutable flow graph version with optimistic concurrency.",
|
package/dist/tools/tools.js
CHANGED
|
@@ -86,7 +86,20 @@ export function createTools(deps) {
|
|
|
86
86
|
// An unconnected portal is a normal state, not an error: the UI shows a connect prompt.
|
|
87
87
|
if (!stored)
|
|
88
88
|
return { portalConnected: false, items: [] };
|
|
89
|
-
|
|
89
|
+
try {
|
|
90
|
+
return { portalConnected: true, items: await capabilities(actor) };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// A token that is gone or beyond renewal is the same answer as never having connected, so
|
|
94
|
+
// reading the catalog reports it as a state. Only a portal that does not answer stays an
|
|
95
|
+
// error — the view has to tell "connect the portal" apart from "the portal failed", and a
|
|
96
|
+
// 401 here would otherwise look like an expired Intel session to the browser.
|
|
97
|
+
if (error instanceof IntelError &&
|
|
98
|
+
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
|
|
99
|
+
return { portalConnected: false, items: [] };
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
90
103
|
},
|
|
91
104
|
execute: async (actor, input) => await call(actor, input),
|
|
92
105
|
async test(actor, input) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- ADR-0004: Knowledge and Flows share one folder tree. A flow gains `parent_id` into the existing
|
|
2
|
+
-- `knowledge_nodes` tree; the contents stay two tables, because versions, the R2 body and the
|
|
3
|
+
-- Vectorize entry belong to the document while the graph, runs and approvals belong to the flow.
|
|
4
|
+
|
|
5
|
+
-- SQLite only accepts an added REFERENCES column when its default is NULL, which is exactly what an
|
|
6
|
+
-- unfiled flow needs: `NULL` is the root of the same tree Knowledge uses.
|
|
7
|
+
ALTER TABLE flows ADD COLUMN parent_id TEXT REFERENCES knowledge_nodes(id);
|
|
8
|
+
|
|
9
|
+
CREATE INDEX flows_parent_idx ON flows(parent_id, archived_at, title);
|
|
10
|
+
|
|
11
|
+
-- Existing flows land in a defined folder rather than scattered across the root. One folder per
|
|
12
|
+
-- owner, with a derived ID so a re-run finds the same row: the narrow reading of an ambiguous case,
|
|
13
|
+
-- as the ADR requires. A single shared folder would have to belong to someone, and its owner would
|
|
14
|
+
-- gain sight of everyone else's flows — a migration must never widen access.
|
|
15
|
+
INSERT INTO knowledge_nodes (
|
|
16
|
+
id, parent_id, kind, title, description, context_policy, owner_id,
|
|
17
|
+
current_version_id, created_at, updated_at, archived_at
|
|
18
|
+
)
|
|
19
|
+
SELECT
|
|
20
|
+
'folder-flows-' || flow.owner_id,
|
|
21
|
+
NULL,
|
|
22
|
+
'folder',
|
|
23
|
+
'Flows',
|
|
24
|
+
'Flows that existed before Knowledge and Flows shared one tree.',
|
|
25
|
+
'explicit',
|
|
26
|
+
flow.owner_id,
|
|
27
|
+
NULL,
|
|
28
|
+
'2026-08-01T00:00:00.000Z',
|
|
29
|
+
'2026-08-01T00:00:00.000Z',
|
|
30
|
+
NULL
|
|
31
|
+
FROM flows flow
|
|
32
|
+
GROUP BY flow.owner_id;
|
|
33
|
+
|
|
34
|
+
UPDATE flows SET parent_id = 'folder-flows-' || owner_id WHERE parent_id IS NULL;
|