@anchrd/intel-api 0.3.1 → 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 +11 -12
- package/dist/adapters/db/db-flows.js +367 -193
- 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 +939 -72
- package/dist/flows/flows.types.d.ts +92 -28
- package/dist/http/http.js +130 -53
- 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 +347 -74
- package/dist/knowledge/knowledge.types.d.ts +58 -27
- package/dist/mcp/mcp.js +153 -71
- 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/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,18 +1,69 @@
|
|
|
1
|
-
import type { CompleteFlowRunStepInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowRun, FlowRunStep, FlowVersion, ListFlowsInput, 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.update" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.share" | "flows.revoke";
|
|
9
49
|
export interface FlowRepository {
|
|
10
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;
|
|
@@ -27,6 +78,7 @@ export interface FlowRepository {
|
|
|
27
78
|
auditId: string;
|
|
28
79
|
}): Promise<"conflict" | Flow>;
|
|
29
80
|
getVersion(versionId: string): Promise<FlowVersion | null>;
|
|
81
|
+
getVersions(versionIds: string[]): Promise<FlowVersion[]>;
|
|
30
82
|
insertVersion(input: {
|
|
31
83
|
version: FlowVersion;
|
|
32
84
|
baseVersionId: string | null;
|
|
@@ -42,21 +94,6 @@ export interface FlowRepository {
|
|
|
42
94
|
auditId: string;
|
|
43
95
|
occurredAt: string;
|
|
44
96
|
}): Promise<boolean>;
|
|
45
|
-
listGrants(flowId: string): Promise<ResourceGrant[]>;
|
|
46
|
-
setGrant(input: {
|
|
47
|
-
grant: ResourceGrant;
|
|
48
|
-
actorId: string;
|
|
49
|
-
idempotencyKey: string;
|
|
50
|
-
auditId: string;
|
|
51
|
-
}): Promise<ResourceGrant>;
|
|
52
|
-
revokeGrant(input: {
|
|
53
|
-
flowId: string;
|
|
54
|
-
grantId: string;
|
|
55
|
-
actorId: string;
|
|
56
|
-
idempotencyKey: string;
|
|
57
|
-
auditId: string;
|
|
58
|
-
occurredAt: string;
|
|
59
|
-
}): Promise<boolean>;
|
|
60
97
|
insertRun(input: {
|
|
61
98
|
run: FlowRun;
|
|
62
99
|
actorId: string;
|
|
@@ -64,6 +101,12 @@ export interface FlowRepository {
|
|
|
64
101
|
auditId: string;
|
|
65
102
|
}): Promise<FlowRun>;
|
|
66
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[]>;
|
|
67
110
|
advanceRun(input: {
|
|
68
111
|
run: FlowRun;
|
|
69
112
|
expectedNodeId: string;
|
|
@@ -86,8 +129,28 @@ export interface FlowDeps {
|
|
|
86
129
|
runtime: FlowRuntime;
|
|
87
130
|
id(): string;
|
|
88
131
|
now(): Date;
|
|
89
|
-
knowledgeExists(actor: FlowActor, resourceId: string): Promise<boolean>;
|
|
90
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>;
|
|
91
154
|
toolFingerprint(actor: FlowActor, toolName: string): Promise<string | null>;
|
|
92
155
|
unavailableTools(actor: FlowActor, toolNames: string[]): Promise<string[]>;
|
|
93
156
|
}
|
|
@@ -97,20 +160,21 @@ export interface FlowService {
|
|
|
97
160
|
items: Flow[];
|
|
98
161
|
}>;
|
|
99
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>;
|
|
100
168
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
101
169
|
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
102
170
|
save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
171
|
+
previewPublish(actor: FlowActor, input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
103
172
|
publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
|
|
104
173
|
start(actor: FlowActor, input: StartFlowRunInput): Promise<FlowRunStep>;
|
|
105
174
|
getRun(actor: FlowActor, runId: string): Promise<FlowRunStep>;
|
|
175
|
+
listRuns(actor: FlowActor, input: ListFlowRunsInput): Promise<FlowRunList>;
|
|
176
|
+
listRunSteps(actor: FlowActor, runId: string): Promise<FlowRunHistory>;
|
|
106
177
|
completeStep(actor: FlowActor, input: CompleteFlowRunStepInput): Promise<FlowRunStep>;
|
|
107
|
-
listGrants(actor: FlowActor, flowId: string): Promise<{
|
|
108
|
-
items: ResourceGrant[];
|
|
109
|
-
}>;
|
|
110
|
-
share(actor: FlowActor, input: ShareFlowInput): Promise<ResourceGrant>;
|
|
111
|
-
revokeGrant(actor: FlowActor, input: RevokeFlowGrantInput): Promise<{
|
|
112
|
-
revoked: boolean;
|
|
113
|
-
}>;
|
|
114
178
|
}
|
|
115
179
|
export interface CompiledFlow {
|
|
116
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,7 +78,7 @@ 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;
|
|
@@ -119,10 +138,44 @@ export function createHttp(deps) {
|
|
|
119
138
|
},
|
|
120
139
|
});
|
|
121
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
|
+
});
|
|
122
163
|
app.get("/knowledge/:nodeId/links", async (context) => {
|
|
123
164
|
const auth = requireCapability(context, "knowledge", "read");
|
|
124
165
|
return context.json(await deps.knowledge.listLinks(asActor(auth), context.req.param("nodeId")));
|
|
125
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
|
+
});
|
|
126
179
|
app.post("/knowledge/search", async (context) => {
|
|
127
180
|
const auth = requireCapability(context, "knowledge", "read");
|
|
128
181
|
const input = SearchKnowledgeInput.parse(await context.req.json().catch(() => null));
|
|
@@ -170,23 +223,6 @@ export function createHttp(deps) {
|
|
|
170
223
|
}
|
|
171
224
|
return context.json(await deps.knowledge.archive(asActor(auth), input));
|
|
172
225
|
});
|
|
173
|
-
app.post("/knowledge/:nodeId/links", async (context) => {
|
|
174
|
-
const auth = requireCapability(context, "knowledge", "write");
|
|
175
|
-
const input = CreateKnowledgeLinkInput.parse(await context.req.json().catch(() => null));
|
|
176
|
-
if (input.sourceNodeId !== context.req.param("nodeId")) {
|
|
177
|
-
throw new IntelError(400, "node_id_mismatch", "Path and body source node IDs differ");
|
|
178
|
-
}
|
|
179
|
-
return context.json(await deps.knowledge.createLink(asActor(auth), input), 201);
|
|
180
|
-
});
|
|
181
|
-
app.post("/knowledge/:nodeId/links/:linkId/revoke", async (context) => {
|
|
182
|
-
const auth = requireCapability(context, "knowledge", "write");
|
|
183
|
-
const input = DeleteKnowledgeLinkInput.parse(await context.req.json().catch(() => null));
|
|
184
|
-
if (input.sourceNodeId !== context.req.param("nodeId") ||
|
|
185
|
-
input.linkId !== context.req.param("linkId")) {
|
|
186
|
-
throw new IntelError(400, "link_id_mismatch", "Path and body Knowledge link IDs differ");
|
|
187
|
-
}
|
|
188
|
-
return context.json(await deps.knowledge.deleteLink(asActor(auth), input));
|
|
189
|
-
});
|
|
190
226
|
app.get("/knowledge/:nodeId/grants", async (context) => {
|
|
191
227
|
const auth = requireCapability(context, "knowledge", "share");
|
|
192
228
|
return context.json(await deps.knowledge.listGrants(asActor(auth), context.req.param("nodeId")));
|
|
@@ -211,45 +247,52 @@ export function createHttp(deps) {
|
|
|
211
247
|
app.get("/flows", async (context) => {
|
|
212
248
|
const auth = requireCapability(context, "flows", "read");
|
|
213
249
|
const url = new URL(context.req.url);
|
|
214
|
-
|
|
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
|
-
}
|
|
250
|
+
requireKnownQuery(url, ["parentId"]);
|
|
222
251
|
// Absent means "every flow I may see"; present but empty means the root of the shared tree.
|
|
223
252
|
// Without that distinction a tree level and a full list would be the same request.
|
|
224
253
|
const parentId = url.searchParams.get("parentId");
|
|
225
254
|
const input = ListFlowsInput.parse(url.searchParams.has("parentId") ? { parentId: parentId === "" ? null : parentId } : {});
|
|
226
255
|
return context.json(await deps.flows.list(asFlowActor(auth), input));
|
|
227
256
|
});
|
|
228
|
-
|
|
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) => {
|
|
229
260
|
const auth = requireCapability(context, "flows", "read");
|
|
230
|
-
|
|
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));
|
|
231
271
|
});
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
274
|
+
app.get("/flows/:flowId", async (context) => {
|
|
275
|
+
const auth = requireCapability(context, "flows", "read");
|
|
276
|
+
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
277
|
+
return context.json(await deps.flows.get(asFlowActor(auth), input.flowId));
|
|
235
278
|
});
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
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));
|
|
243
285
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
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));
|
|
252
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).
|
|
253
296
|
app.post("/flows", async (context) => {
|
|
254
297
|
const auth = requireCapability(context, "flows", "create");
|
|
255
298
|
const input = CreateFlowInput.parse(await context.req.json().catch(() => null));
|
|
@@ -271,6 +314,16 @@ export function createHttp(deps) {
|
|
|
271
314
|
}
|
|
272
315
|
return context.json(await deps.flows.save(asFlowActor(auth), input), 201);
|
|
273
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
|
+
});
|
|
274
327
|
app.post("/flows/:flowId/publish", async (context) => {
|
|
275
328
|
const auth = requireCapability(context, "flows", "publish");
|
|
276
329
|
const input = PublishFlowInput.parse(await context.req.json().catch(() => null));
|
|
@@ -287,9 +340,33 @@ export function createHttp(deps) {
|
|
|
287
340
|
}
|
|
288
341
|
return context.json(await deps.flows.start(asFlowActor(auth), input), 201);
|
|
289
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
|
+
});
|
|
290
360
|
app.get("/flow-runs/:runId", async (context) => {
|
|
291
361
|
const auth = requireCapability(context, "flows", "read");
|
|
292
|
-
|
|
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));
|
|
293
370
|
});
|
|
294
371
|
app.post("/flow-runs/:runId/complete", async (context) => {
|
|
295
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
|
+
}
|