@anchrd/intel-api 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/cloudflare/cloudflare.js +12 -12
- package/dist/adapters/db/db-flows.js +417 -188
- package/dist/adapters/db/db-grants.d.ts +47 -0
- package/dist/adapters/db/db-grants.js +125 -0
- package/dist/adapters/db/db-indexing.js +14 -3
- package/dist/adapters/db/db.js +236 -202
- package/dist/flows/flows.d.ts +31 -1
- package/dist/flows/flows.js +988 -74
- package/dist/flows/flows.types.d.ts +104 -30
- package/dist/http/http.js +154 -46
- package/dist/indexing/indexing.js +14 -3
- package/dist/knowledge/document-links/document-links.d.ts +15 -0
- package/dist/knowledge/document-links/document-links.js +52 -0
- package/dist/knowledge/knowledge.js +357 -73
- package/dist/knowledge/knowledge.types.d.ts +60 -27
- package/dist/mcp/mcp.js +168 -74
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +1 -0
- package/dist/shared/gate-authorization/gate-authorization.js +7 -0
- package/dist/tools/tools.js +14 -1
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +39 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/package.json +1 -1
|
@@ -1,25 +1,84 @@
|
|
|
1
|
-
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, PublishFlowInput,
|
|
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";
|
|
2
|
+
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
3
|
+
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
4
|
+
export interface FlowRunChainEntry {
|
|
5
|
+
runId: string;
|
|
6
|
+
flowId: string;
|
|
7
|
+
flowTitle: string;
|
|
8
|
+
versionId: string;
|
|
9
|
+
parentRunId: string | null;
|
|
10
|
+
currentNodeId: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ListRunsQuery {
|
|
13
|
+
flowId: string;
|
|
14
|
+
failedOnly: boolean;
|
|
15
|
+
limit: number;
|
|
16
|
+
cursor: {
|
|
17
|
+
createdAt: string;
|
|
18
|
+
id: string;
|
|
19
|
+
} | null;
|
|
20
|
+
}
|
|
21
|
+
export interface FlowCallSite {
|
|
22
|
+
runId: string;
|
|
23
|
+
nodeId: string;
|
|
24
|
+
}
|
|
25
|
+
export interface FlowVisibleCall extends FlowCallSite {
|
|
26
|
+
calledRunId: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FlowRunStepRow {
|
|
29
|
+
runId: string;
|
|
30
|
+
nodeId: string;
|
|
31
|
+
outcome: "completed" | "failed";
|
|
32
|
+
branch: string | null;
|
|
33
|
+
error: string | null;
|
|
34
|
+
completedAt: string;
|
|
35
|
+
}
|
|
2
36
|
export interface FlowActor {
|
|
3
37
|
id: string;
|
|
4
38
|
email: string;
|
|
5
39
|
canRun: boolean;
|
|
6
40
|
canApprove: boolean;
|
|
41
|
+
isAdmin?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export type FlowVerb = Extract<ResourceVerb, "read" | "write" | "execute">;
|
|
44
|
+
export type FlowOperation = "flows.create" | "flows.update" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete";
|
|
45
|
+
export interface BoundedLevel<T> {
|
|
46
|
+
items: T[];
|
|
47
|
+
total: number;
|
|
7
48
|
}
|
|
8
|
-
export type FlowOperation = "flows.create" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
|
|
9
49
|
export interface FlowRepository {
|
|
10
|
-
listVisible(actor: FlowActor): Promise<Flow[]>;
|
|
50
|
+
listVisible(actor: FlowActor, input?: ListFlowsInput): Promise<Flow[]>;
|
|
51
|
+
listVisibleBounded(actor: FlowActor, folderId: string | null, limit: number): Promise<BoundedLevel<Flow>>;
|
|
11
52
|
getVisible(actor: FlowActor, flowId: string): Promise<Flow | null>;
|
|
12
|
-
|
|
13
|
-
|
|
53
|
+
can(actor: FlowActor, flowId: string, verb: FlowVerb): Promise<boolean>;
|
|
54
|
+
getCallable(actor: FlowActor, flowId: string): Promise<Flow | null>;
|
|
55
|
+
listCallable(actor: FlowActor, flowIds: string[]): Promise<Flow[]>;
|
|
56
|
+
callReach(callerFolderId: string | null, calleeFolderId: string | null): Promise<FlowCallReach>;
|
|
57
|
+
externalCallers(actor: FlowPrincipal, folderId: string): Promise<{
|
|
58
|
+
visible: string[];
|
|
59
|
+
hidden: number;
|
|
60
|
+
}>;
|
|
61
|
+
knowledgeReferences(actor: FlowPrincipal, folderId: string): Promise<string[]>;
|
|
62
|
+
publishedCallees(flowId: string): Promise<{
|
|
63
|
+
title: string;
|
|
64
|
+
calleeIds: string[];
|
|
65
|
+
} | null>;
|
|
14
66
|
findIdempotent(actorId: string, operation: FlowOperation, key: string): Promise<string | null>;
|
|
15
|
-
findIdempotentRevocation(actorId: string, key: string): Promise<boolean | null>;
|
|
16
67
|
insertFlow(input: {
|
|
17
68
|
flow: Flow;
|
|
18
69
|
actorId: string;
|
|
19
70
|
idempotencyKey: string;
|
|
20
71
|
auditId: string;
|
|
21
72
|
}): Promise<Flow>;
|
|
73
|
+
updateFlow(input: {
|
|
74
|
+
flow: Flow;
|
|
75
|
+
baseUpdatedAt: string;
|
|
76
|
+
actorId: string;
|
|
77
|
+
idempotencyKey: string;
|
|
78
|
+
auditId: string;
|
|
79
|
+
}): Promise<"conflict" | Flow>;
|
|
22
80
|
getVersion(versionId: string): Promise<FlowVersion | null>;
|
|
81
|
+
getVersions(versionIds: string[]): Promise<FlowVersion[]>;
|
|
23
82
|
insertVersion(input: {
|
|
24
83
|
version: FlowVersion;
|
|
25
84
|
baseVersionId: string | null;
|
|
@@ -35,21 +94,6 @@ export interface FlowRepository {
|
|
|
35
94
|
auditId: string;
|
|
36
95
|
occurredAt: string;
|
|
37
96
|
}): Promise<boolean>;
|
|
38
|
-
listGrants(flowId: string): Promise<ResourceGrant[]>;
|
|
39
|
-
setGrant(input: {
|
|
40
|
-
grant: ResourceGrant;
|
|
41
|
-
actorId: string;
|
|
42
|
-
idempotencyKey: string;
|
|
43
|
-
auditId: string;
|
|
44
|
-
}): Promise<ResourceGrant>;
|
|
45
|
-
revokeGrant(input: {
|
|
46
|
-
flowId: string;
|
|
47
|
-
grantId: string;
|
|
48
|
-
actorId: string;
|
|
49
|
-
idempotencyKey: string;
|
|
50
|
-
auditId: string;
|
|
51
|
-
occurredAt: string;
|
|
52
|
-
}): Promise<boolean>;
|
|
53
97
|
insertRun(input: {
|
|
54
98
|
run: FlowRun;
|
|
55
99
|
actorId: string;
|
|
@@ -57,6 +101,12 @@ export interface FlowRepository {
|
|
|
57
101
|
auditId: string;
|
|
58
102
|
}): Promise<FlowRun>;
|
|
59
103
|
getRunVisible(actor: FlowActor, runId: string): Promise<FlowRun | null>;
|
|
104
|
+
listRunsVisible(actor: FlowActor, input: ListRunsQuery): Promise<FlowRun[]>;
|
|
105
|
+
failedSteps(runIds: string[]): Promise<FlowRunStepRow[]>;
|
|
106
|
+
runSteps(runId: string): Promise<FlowRunStepRow[]>;
|
|
107
|
+
visibleCallRuns(actor: FlowActor, sites: FlowCallSite[]): Promise<FlowVisibleCall[]>;
|
|
108
|
+
findChildRun(parentRunId: string, parentNodeId: string): Promise<FlowRun | null>;
|
|
109
|
+
runChain(runId: string): Promise<FlowRunChainEntry[]>;
|
|
60
110
|
advanceRun(input: {
|
|
61
111
|
run: FlowRun;
|
|
62
112
|
expectedNodeId: string;
|
|
@@ -79,28 +129,52 @@ export interface FlowDeps {
|
|
|
79
129
|
runtime: FlowRuntime;
|
|
80
130
|
id(): string;
|
|
81
131
|
now(): Date;
|
|
82
|
-
|
|
132
|
+
folderAccess(actor: FlowActor, folderId: string): Promise<FolderAccess>;
|
|
133
|
+
/**
|
|
134
|
+
* The children of one folder of the shared tree as this actor may see them, without their bodies.
|
|
135
|
+
* `null` is the root. Knowledge answers, for the same reason `folderAccess` does.
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ `limit` bounds the read, not its result: its one caller draws a bounded picture, and a folder
|
|
138
|
+
* of a thousand documents must not be loaded whole for it (#30). What the bound left behind comes
|
|
139
|
+
* back as `total`, counted among the nodes this actor may see and no others — a number that
|
|
140
|
+
* included the rest would say that the rest is there.
|
|
141
|
+
*/
|
|
142
|
+
knowledgeChildren(actor: FlowActor, folderId: string | null, limit: number): Promise<BoundedLevel<KnowledgeNode>>;
|
|
143
|
+
/**
|
|
144
|
+
* One node as this actor may see it. `null` means "this actor cannot reach it", never "it is
|
|
145
|
+
* gone" — the relation graph must then leave it out altogether rather than draw a placeholder,
|
|
146
|
+
* because the edge alone would already give away that it exists (#19).
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ Three jobs, one door, on purpose. The relation graph draws what comes back here; the
|
|
149
|
+
* requirements list names it; and every Knowledge step of every run is authorized by it, nested
|
|
150
|
+
* calls included (ADR-0004 §4). A second lookup beside it is how a drawing or a message ends up
|
|
151
|
+
* kinder than the door it describes — which is what #17 and #19 were sent back for.
|
|
152
|
+
*/
|
|
153
|
+
visibleKnowledge(actor: FlowActor, nodeId: string): Promise<KnowledgeNode | null>;
|
|
83
154
|
toolFingerprint(actor: FlowActor, toolName: string): Promise<string | null>;
|
|
84
155
|
unavailableTools(actor: FlowActor, toolNames: string[]): Promise<string[]>;
|
|
85
156
|
}
|
|
157
|
+
export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
|
|
86
158
|
export interface FlowService {
|
|
87
|
-
list(actor: FlowActor): Promise<{
|
|
159
|
+
list(actor: FlowActor, input?: ListFlowsInput): Promise<{
|
|
88
160
|
items: Flow[];
|
|
89
161
|
}>;
|
|
90
162
|
get(actor: FlowActor, flowId: string): Promise<FlowDocument>;
|
|
163
|
+
listCalls(actor: FlowActor, flowId: string): Promise<{
|
|
164
|
+
items: Flow[];
|
|
165
|
+
}>;
|
|
166
|
+
relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
|
|
167
|
+
listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
|
|
91
168
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
169
|
+
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
92
170
|
save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
171
|
+
previewPublish(actor: FlowActor, input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
93
172
|
publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
|
|
94
173
|
start(actor: FlowActor, input: StartFlowRunInput): Promise<FlowRunStep>;
|
|
95
174
|
getRun(actor: FlowActor, runId: string): Promise<FlowRunStep>;
|
|
175
|
+
listRuns(actor: FlowActor, input: ListFlowRunsInput): Promise<FlowRunList>;
|
|
176
|
+
listRunSteps(actor: FlowActor, runId: string): Promise<FlowRunHistory>;
|
|
96
177
|
completeStep(actor: FlowActor, input: CompleteFlowRunStepInput): Promise<FlowRunStep>;
|
|
97
|
-
listGrants(actor: FlowActor, flowId: string): Promise<{
|
|
98
|
-
items: ResourceGrant[];
|
|
99
|
-
}>;
|
|
100
|
-
share(actor: FlowActor, input: ShareFlowInput): Promise<ResourceGrant>;
|
|
101
|
-
revokeGrant(actor: FlowActor, input: RevokeFlowGrantInput): Promise<{
|
|
102
|
-
revoked: boolean;
|
|
103
|
-
}>;
|
|
104
178
|
}
|
|
105
179
|
export interface CompiledFlow {
|
|
106
180
|
graph: FlowGraph;
|
package/dist/http/http.js
CHANGED
|
@@ -1,21 +1,39 @@
|
|
|
1
|
-
import { ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput,
|
|
1
|
+
import { AppendKnowledgeTableRowsInput, ArchiveKnowledgeNodeInput, CompleteFlowRunStepInput, CreateFlowInput, CreateKnowledgeNodeInput, DefineKnowledgeTableInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, KnowledgeGraphInput, ListFlowRunsInput, ListFlowsInput, ListKnowledgeNodesInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, ResolveKnowledgeLinksInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateFlowInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { authorizeBearer, bearer } from "../shared/gate-authorization/gate-authorization.js";
|
|
4
|
+
import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
|
|
5
5
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
6
6
|
import { problemDetails as problem } from "../shared/problem-details/problem-details.js";
|
|
7
|
+
// A query string is a door to the outside like a body is, so what arrives through it is closed
|
|
8
|
+
// rather than tolerated: `z.strictObject` refuses an unknown field, and this is that refusal for the
|
|
9
|
+
// half of the input Zod never sees. A silently ignored parameter is how a caller believes it asked
|
|
10
|
+
// for something it did not get — a mistyped `limit=` that quietly draws everything, for instance.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ Not the same decision as `selectionSearch` in the UI, which deliberately passes unknown search
|
|
13
|
+
// parameters through so the portal's return marker survives a redirect. That is a browser location;
|
|
14
|
+
// this is an API route.
|
|
15
|
+
function requireKnownQuery(url, allowed) {
|
|
16
|
+
const unknown = [];
|
|
17
|
+
url.searchParams.forEach((_value, key) => {
|
|
18
|
+
if (!allowed.includes(key))
|
|
19
|
+
unknown.push(key);
|
|
20
|
+
});
|
|
21
|
+
if (unknown.length) {
|
|
22
|
+
throw new IntelError(400, "invalid_request", `Unknown query parameters: ${unknown.join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
7
25
|
function asActor(authorization) {
|
|
8
26
|
return {
|
|
9
27
|
id: authorization.identity.id,
|
|
10
28
|
email: authorization.identity.email,
|
|
11
|
-
|
|
29
|
+
isAdmin: authorization.can("intel", "admin"),
|
|
12
30
|
};
|
|
13
31
|
}
|
|
14
32
|
function asToolActor(authorization) {
|
|
15
33
|
return {
|
|
16
34
|
id: authorization.identity.id,
|
|
17
35
|
email: authorization.identity.email,
|
|
18
|
-
canExecute: authorization
|
|
36
|
+
canExecute: permits(authorization, "tools", "execute"),
|
|
19
37
|
};
|
|
20
38
|
}
|
|
21
39
|
// RFC 5987 ext-value: encodeURIComponent leaves ' * ! ( ) unescaped, and a bare apostrophe is a
|
|
@@ -29,8 +47,9 @@ function asFlowActor(authorization) {
|
|
|
29
47
|
return {
|
|
30
48
|
id: authorization.identity.id,
|
|
31
49
|
email: authorization.identity.email,
|
|
32
|
-
canRun: authorization
|
|
33
|
-
canApprove: authorization
|
|
50
|
+
canRun: permits(authorization, "flows", "run"),
|
|
51
|
+
canApprove: permits(authorization, "flows", "approve"),
|
|
52
|
+
isAdmin: authorization.can("intel", "admin"),
|
|
34
53
|
};
|
|
35
54
|
}
|
|
36
55
|
export function createHttp(deps) {
|
|
@@ -59,11 +78,21 @@ export function createHttp(deps) {
|
|
|
59
78
|
});
|
|
60
79
|
function requireCapability(context, handle, fn) {
|
|
61
80
|
const authorization = context.get("authorization");
|
|
62
|
-
if (!authorization
|
|
81
|
+
if (!permits(authorization, handle, fn)) {
|
|
63
82
|
throw new IntelError(403, "permission_required", "Permission required");
|
|
64
83
|
}
|
|
65
84
|
return authorization;
|
|
66
85
|
}
|
|
86
|
+
// The caller's own identity, so the shell can name who is signed in. It needs no capability —
|
|
87
|
+
// the answer is the token's own subject — and it returns nothing but id, email, and name.
|
|
88
|
+
app.get("/session", (context) => {
|
|
89
|
+
const { identity } = context.get("authorization");
|
|
90
|
+
return context.json({
|
|
91
|
+
id: identity.id,
|
|
92
|
+
email: identity.email,
|
|
93
|
+
name: identity.name ?? null,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
67
96
|
app.get("/knowledge", async (context) => {
|
|
68
97
|
const auth = requireCapability(context, "knowledge", "read");
|
|
69
98
|
const url = new URL(context.req.url);
|
|
@@ -109,10 +138,44 @@ export function createHttp(deps) {
|
|
|
109
138
|
},
|
|
110
139
|
});
|
|
111
140
|
});
|
|
141
|
+
app.get("/knowledge/:nodeId/table", async (context) => {
|
|
142
|
+
const auth = requireCapability(context, "knowledge", "read");
|
|
143
|
+
return context.json(await deps.knowledge.getTable(asActor(auth), context.req.param("nodeId")));
|
|
144
|
+
});
|
|
145
|
+
// Defining the header and appending rows are two routes because they are two decisions: the
|
|
146
|
+
// header is written once and is the contract, an append is the everyday write (#40).
|
|
147
|
+
app.post("/knowledge/:nodeId/table", async (context) => {
|
|
148
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
149
|
+
const input = DefineKnowledgeTableInput.parse(await context.req.json().catch(() => null));
|
|
150
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
151
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
152
|
+
}
|
|
153
|
+
return context.json(await deps.knowledge.defineTable(asActor(auth), input), 201);
|
|
154
|
+
});
|
|
155
|
+
app.post("/knowledge/:nodeId/table/rows", async (context) => {
|
|
156
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
157
|
+
const input = AppendKnowledgeTableRowsInput.parse(await context.req.json().catch(() => null));
|
|
158
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
159
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
160
|
+
}
|
|
161
|
+
return context.json(await deps.knowledge.appendTableRows(asActor(auth), input), 201);
|
|
162
|
+
});
|
|
112
163
|
app.get("/knowledge/:nodeId/links", async (context) => {
|
|
113
164
|
const auth = requireCapability(context, "knowledge", "read");
|
|
114
165
|
return context.json(await deps.knowledge.listLinks(asActor(auth), context.req.param("nodeId")));
|
|
115
166
|
});
|
|
167
|
+
// A document link's name, for whoever is reading the text (#41). A POST because the list of IDs
|
|
168
|
+
// is the request body — putting them in a query string would put the tree's identifiers into
|
|
169
|
+
// logs and referrers. `knowledge/read` and nothing more: it answers with what this reader may
|
|
170
|
+
// already see, and stays silent about the rest rather than saying that there is a rest.
|
|
171
|
+
//
|
|
172
|
+
// There is no route to create or delete a link any more. A relationship is written where it is
|
|
173
|
+
// meant, in the text, and `POST /knowledge/:nodeId/versions` is what records it.
|
|
174
|
+
app.post("/knowledge/links/resolve", async (context) => {
|
|
175
|
+
const auth = requireCapability(context, "knowledge", "read");
|
|
176
|
+
const input = ResolveKnowledgeLinksInput.parse(await context.req.json().catch(() => null));
|
|
177
|
+
return context.json(await deps.knowledge.resolveLinks(asActor(auth), input));
|
|
178
|
+
});
|
|
116
179
|
app.post("/knowledge/search", async (context) => {
|
|
117
180
|
const auth = requireCapability(context, "knowledge", "read");
|
|
118
181
|
const input = SearchKnowledgeInput.parse(await context.req.json().catch(() => null));
|
|
@@ -160,23 +223,6 @@ export function createHttp(deps) {
|
|
|
160
223
|
}
|
|
161
224
|
return context.json(await deps.knowledge.archive(asActor(auth), input));
|
|
162
225
|
});
|
|
163
|
-
app.post("/knowledge/:nodeId/links", async (context) => {
|
|
164
|
-
const auth = requireCapability(context, "knowledge", "write");
|
|
165
|
-
const input = CreateKnowledgeLinkInput.parse(await context.req.json().catch(() => null));
|
|
166
|
-
if (input.sourceNodeId !== context.req.param("nodeId")) {
|
|
167
|
-
throw new IntelError(400, "node_id_mismatch", "Path and body source node IDs differ");
|
|
168
|
-
}
|
|
169
|
-
return context.json(await deps.knowledge.createLink(asActor(auth), input), 201);
|
|
170
|
-
});
|
|
171
|
-
app.post("/knowledge/:nodeId/links/:linkId/revoke", async (context) => {
|
|
172
|
-
const auth = requireCapability(context, "knowledge", "write");
|
|
173
|
-
const input = DeleteKnowledgeLinkInput.parse(await context.req.json().catch(() => null));
|
|
174
|
-
if (input.sourceNodeId !== context.req.param("nodeId") ||
|
|
175
|
-
input.linkId !== context.req.param("linkId")) {
|
|
176
|
-
throw new IntelError(400, "link_id_mismatch", "Path and body Knowledge link IDs differ");
|
|
177
|
-
}
|
|
178
|
-
return context.json(await deps.knowledge.deleteLink(asActor(auth), input));
|
|
179
|
-
});
|
|
180
226
|
app.get("/knowledge/:nodeId/grants", async (context) => {
|
|
181
227
|
const auth = requireCapability(context, "knowledge", "share");
|
|
182
228
|
return context.json(await deps.knowledge.listGrants(asActor(auth), context.req.param("nodeId")));
|
|
@@ -200,38 +246,66 @@ export function createHttp(deps) {
|
|
|
200
246
|
});
|
|
201
247
|
app.get("/flows", async (context) => {
|
|
202
248
|
const auth = requireCapability(context, "flows", "read");
|
|
203
|
-
|
|
249
|
+
const url = new URL(context.req.url);
|
|
250
|
+
requireKnownQuery(url, ["parentId"]);
|
|
251
|
+
// Absent means "every flow I may see"; present but empty means the root of the shared tree.
|
|
252
|
+
// Without that distinction a tree level and a full list would be the same request.
|
|
253
|
+
const parentId = url.searchParams.get("parentId");
|
|
254
|
+
const input = ListFlowsInput.parse(url.searchParams.has("parentId") ? { parentId: parentId === "" ? null : parentId } : {});
|
|
255
|
+
return context.json(await deps.flows.list(asFlowActor(auth), input));
|
|
256
|
+
});
|
|
257
|
+
// ⚠️ Before `/flows/:flowId`, or the router would read "graph" as a flow ID. Same order and same
|
|
258
|
+
// reason as `/knowledge/graph`.
|
|
259
|
+
app.get("/flows/graph", async (context) => {
|
|
260
|
+
const auth = requireCapability(context, "flows", "read");
|
|
261
|
+
const url = new URL(context.req.url);
|
|
262
|
+
requireKnownQuery(url, ["of", "folderId", "flowId", "limit"]);
|
|
263
|
+
const of = url.searchParams.get("of");
|
|
264
|
+
const input = RelationGraphInput.parse({
|
|
265
|
+
scope: of === "flow"
|
|
266
|
+
? { of: "flow", flowId: url.searchParams.get("flowId") ?? "" }
|
|
267
|
+
: { of: "folder", folderId: url.searchParams.get("folderId") || null },
|
|
268
|
+
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
|
269
|
+
});
|
|
270
|
+
return context.json(await deps.flows.relationGraph(asFlowActor(auth), input));
|
|
204
271
|
});
|
|
272
|
+
// A path variable is a door like any other, so it is parsed rather than trusted: `GetFlowInput`
|
|
273
|
+
// is the same schema the body-carrying routes and the MCP tools use for this identifier.
|
|
205
274
|
app.get("/flows/:flowId", async (context) => {
|
|
206
275
|
const auth = requireCapability(context, "flows", "read");
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
app.get("/flows/:flowId/grants", async (context) => {
|
|
210
|
-
const auth = requireCapability(context, "flows", "share");
|
|
211
|
-
return context.json(await deps.flows.listGrants(asFlowActor(auth), context.req.param("flowId")));
|
|
276
|
+
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
277
|
+
return context.json(await deps.flows.get(asFlowActor(auth), input.flowId));
|
|
212
278
|
});
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return context.json(await deps.flows.share(asFlowActor(auth), input), 201);
|
|
279
|
+
// What this flow calls, read out of its graph. The tree lists where a flow is filed; this lists
|
|
280
|
+
// what it runs, and a shared subflow answers both questions at once (ADR-0004 §3).
|
|
281
|
+
app.get("/flows/:flowId/calls", async (context) => {
|
|
282
|
+
const auth = requireCapability(context, "flows", "read");
|
|
283
|
+
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
284
|
+
return context.json(await deps.flows.listCalls(asFlowActor(auth), input.flowId));
|
|
220
285
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
return context.json(await deps.flows.revokeGrant(asFlowActor(auth), input));
|
|
286
|
+
// What this flow needs: the documents and tools its graph names. `flows/read` and nothing more —
|
|
287
|
+
// it describes a flow the caller may already open, and names only what they may already see.
|
|
288
|
+
app.get("/flows/:flowId/requirements", async (context) => {
|
|
289
|
+
const auth = requireCapability(context, "flows", "read");
|
|
290
|
+
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
291
|
+
return context.json(await deps.flows.listRequirements(asFlowActor(auth), input.flowId));
|
|
229
292
|
});
|
|
293
|
+
// A flow has no grant route of its own. It is shared through the folder it is filed in, under
|
|
294
|
+
// /knowledge/:nodeId/grants — one place answers the question for the documents and the flows in
|
|
295
|
+
// that folder alike (ADR-0004 §2).
|
|
230
296
|
app.post("/flows", async (context) => {
|
|
231
297
|
const auth = requireCapability(context, "flows", "create");
|
|
232
298
|
const input = CreateFlowInput.parse(await context.req.json().catch(() => null));
|
|
233
299
|
return context.json(await deps.flows.create(asFlowActor(auth), input), 201);
|
|
234
300
|
});
|
|
301
|
+
app.patch("/flows/:flowId", async (context) => {
|
|
302
|
+
const auth = requireCapability(context, "flows", "write");
|
|
303
|
+
const input = UpdateFlowInput.parse(await context.req.json().catch(() => null));
|
|
304
|
+
if (input.flowId !== context.req.param("flowId")) {
|
|
305
|
+
throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
|
|
306
|
+
}
|
|
307
|
+
return context.json(await deps.flows.update(asFlowActor(auth), input));
|
|
308
|
+
});
|
|
235
309
|
app.post("/flows/:flowId/versions", async (context) => {
|
|
236
310
|
const auth = requireCapability(context, "flows", "write");
|
|
237
311
|
const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
|
|
@@ -240,6 +314,16 @@ export function createHttp(deps) {
|
|
|
240
314
|
}
|
|
241
315
|
return context.json(await deps.flows.save(asFlowActor(auth), input), 201);
|
|
242
316
|
});
|
|
317
|
+
// What publishing would freeze, before it is published (ADR-0004 §5). A GET because it reads and
|
|
318
|
+
// changes nothing; the `publish` capability because it answers a question only a publisher has.
|
|
319
|
+
app.get("/flows/:flowId/versions/:versionId/publish-preview", async (context) => {
|
|
320
|
+
const auth = requireCapability(context, "flows", "publish");
|
|
321
|
+
const input = PreviewFlowPublishInput.parse({
|
|
322
|
+
flowId: context.req.param("flowId"),
|
|
323
|
+
versionId: context.req.param("versionId"),
|
|
324
|
+
});
|
|
325
|
+
return context.json(await deps.flows.previewPublish(asFlowActor(auth), input));
|
|
326
|
+
});
|
|
243
327
|
app.post("/flows/:flowId/publish", async (context) => {
|
|
244
328
|
const auth = requireCapability(context, "flows", "publish");
|
|
245
329
|
const input = PublishFlowInput.parse(await context.req.json().catch(() => null));
|
|
@@ -256,9 +340,33 @@ export function createHttp(deps) {
|
|
|
256
340
|
}
|
|
257
341
|
return context.json(await deps.flows.start(asFlowActor(auth), input), 201);
|
|
258
342
|
});
|
|
343
|
+
// What this flow has done (#35). A GET beside `POST /flows/:flowId/runs`: the same collection,
|
|
344
|
+
// read instead of appended to. `flows/read` is the capability, and which of the runs actually
|
|
345
|
+
// appear is the resource rule the service applies per row.
|
|
346
|
+
app.get("/flows/:flowId/runs", async (context) => {
|
|
347
|
+
const auth = requireCapability(context, "flows", "read");
|
|
348
|
+
const url = new URL(context.req.url);
|
|
349
|
+
requireKnownQuery(url, ["failedOnly", "limit", "cursor"]);
|
|
350
|
+
const input = ListFlowRunsInput.parse({
|
|
351
|
+
flowId: context.req.param("flowId"),
|
|
352
|
+
// Present means "only the failed ones". A query string carries no booleans, so the parameter's
|
|
353
|
+
// presence is the flag rather than a string that would have to be spelled exactly right.
|
|
354
|
+
...(url.searchParams.has("failedOnly") ? { failedOnly: true } : {}),
|
|
355
|
+
...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}),
|
|
356
|
+
...(url.searchParams.has("cursor") ? { cursor: url.searchParams.get("cursor") } : {}),
|
|
357
|
+
});
|
|
358
|
+
return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
|
|
359
|
+
});
|
|
259
360
|
app.get("/flow-runs/:runId", async (context) => {
|
|
260
361
|
const auth = requireCapability(context, "flows", "read");
|
|
261
|
-
|
|
362
|
+
const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
|
|
363
|
+
return context.json(await deps.flows.getRun(asFlowActor(auth), input.runId));
|
|
364
|
+
});
|
|
365
|
+
// The drill-down behind the list: which step of this run ended it, and what it said.
|
|
366
|
+
app.get("/flow-runs/:runId/steps", async (context) => {
|
|
367
|
+
const auth = requireCapability(context, "flows", "read");
|
|
368
|
+
const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
|
|
369
|
+
return context.json(await deps.flows.listRunSteps(asFlowActor(auth), input.runId));
|
|
262
370
|
});
|
|
263
371
|
app.post("/flow-runs/:runId/complete", async (context) => {
|
|
264
372
|
const auth = requireCapability(context, "flows", "run");
|
|
@@ -13,15 +13,26 @@ function indexText(mediaType, content) {
|
|
|
13
13
|
throw new PermanentIndexingError(`Knowledge version content is not a valid BlockNote document: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
async function readCanonical(deps, target) {
|
|
17
|
+
if (target.kind === "attachment") {
|
|
18
|
+
const key = target.contentKeys[0];
|
|
19
|
+
return key === undefined ? null : await deps.content.getBytes(key);
|
|
20
|
+
}
|
|
21
|
+
const segments = await Promise.all(target.contentKeys.map(async (key) => await deps.content.get(key)));
|
|
22
|
+
if (segments.length === 0 || segments.some((segment) => segment === null))
|
|
23
|
+
return null;
|
|
24
|
+
return segments.join("");
|
|
25
|
+
}
|
|
16
26
|
export function createIndexing(deps) {
|
|
17
27
|
return {
|
|
18
28
|
async index(versionId) {
|
|
19
29
|
const target = await deps.repository.getTarget(versionId);
|
|
20
30
|
if (!target)
|
|
21
31
|
return;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
// An attachment is one object of bytes; text kinds may be several, because a table's content
|
|
33
|
+
// is the join of its versions (#40). A missing segment fails the whole pass rather than
|
|
34
|
+
// indexing a table with a hole in it.
|
|
35
|
+
const canonical = await readCanonical(deps, target);
|
|
25
36
|
if (canonical === null) {
|
|
26
37
|
await deps.repository.markError(versionId, "content_missing", deps.now().toISOString());
|
|
27
38
|
throw new Error("Knowledge version content is missing");
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents one saved document links to, read out of its own content (#41).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ The IDs and nothing else. A document link stores no title and no path, so there is nothing in
|
|
5
|
+
* here that could go stale when the target is renamed or moved — and nothing that could carry a
|
|
6
|
+
* name out of a part of the tree the reader may not see.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ Deliberately tolerant of shapes it does not know. This walks stored content, which an older or
|
|
9
|
+
* newer editor wrote; a strict parse would turn "I do not recognise this block" into "this document
|
|
10
|
+
* cannot be saved". What it does not recognise contributes no link, which is the safe half.
|
|
11
|
+
*
|
|
12
|
+
* Content that is not a BlockNote document has no links at all: an attachment, a CSV table or plain
|
|
13
|
+
* markdown carries no inline element to read.
|
|
14
|
+
*/
|
|
15
|
+
export declare function documentLinkTargets(mediaType: string, content: string): string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { BlockNoteDocument, BlockNoteMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === "object" && value !== null;
|
|
4
|
+
}
|
|
5
|
+
function collect(value, found) {
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
for (const entry of value)
|
|
8
|
+
collect(entry, found);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (!isRecord(value))
|
|
12
|
+
return;
|
|
13
|
+
if (value.type === DocumentLinkInlineType) {
|
|
14
|
+
const props = value.props;
|
|
15
|
+
const nodeId = isRecord(props) ? props.nodeId : undefined;
|
|
16
|
+
if (typeof nodeId === "string" && nodeId.length > 0)
|
|
17
|
+
found.add(nodeId);
|
|
18
|
+
}
|
|
19
|
+
collect(value.content, found);
|
|
20
|
+
collect(value.children, found);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The documents one saved document links to, read out of its own content (#41).
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ The IDs and nothing else. A document link stores no title and no path, so there is nothing in
|
|
26
|
+
* here that could go stale when the target is renamed or moved — and nothing that could carry a
|
|
27
|
+
* name out of a part of the tree the reader may not see.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ Deliberately tolerant of shapes it does not know. This walks stored content, which an older or
|
|
30
|
+
* newer editor wrote; a strict parse would turn "I do not recognise this block" into "this document
|
|
31
|
+
* cannot be saved". What it does not recognise contributes no link, which is the safe half.
|
|
32
|
+
*
|
|
33
|
+
* Content that is not a BlockNote document has no links at all: an attachment, a CSV table or plain
|
|
34
|
+
* markdown carries no inline element to read.
|
|
35
|
+
*/
|
|
36
|
+
export function documentLinkTargets(mediaType, content) {
|
|
37
|
+
if (mediaType !== BlockNoteMediaType)
|
|
38
|
+
return [];
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(content);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const document = BlockNoteDocument.safeParse(parsed);
|
|
47
|
+
if (!document.success)
|
|
48
|
+
return [];
|
|
49
|
+
const found = new Set();
|
|
50
|
+
collect(document.data.blocks, found);
|
|
51
|
+
return [...found];
|
|
52
|
+
}
|