@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.
@@ -1,25 +1,84 @@
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, 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
- canEdit(actor: FlowActor, flowId: string): Promise<boolean>;
13
- canManage(actor: FlowActor, flowId: string): Promise<boolean>;
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
- knowledgeExists(actor: FlowActor, resourceId: string): Promise<boolean>;
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, CreateKnowledgeLinkInput, CreateKnowledgeNodeInput, DeleteKnowledgeLinkInput, ExecuteToolInput, KnowledgeGraphInput, ListKnowledgeNodesInput, PublishFlowInput, RevokeFlowGrantInput, RevokeKnowledgeGrantInput, SaveFlowVersionInput, SaveKnowledgeAttachmentInput, SaveKnowledgeVersionInput, SearchKnowledgeInput, ShareFlowInput, ShareKnowledgeInput, StartFlowRunInput, TestToolInput, UpdateKnowledgeNodeInput, } from "@anchrd/intel-contract";
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
- canReindex: authorization.can("intel", "admin"),
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.can("tools", "execute"),
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.can("flows", "run"),
33
- canApprove: authorization.can("flows", "approve"),
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.can(handle, fn)) {
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
- return context.json(await deps.flows.list(asFlowActor(auth)));
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
- return context.json(await deps.flows.get(asFlowActor(auth), context.req.param("flowId")));
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
- app.post("/flows/:flowId/grants", async (context) => {
214
- const auth = requireCapability(context, "flows", "share");
215
- const input = ShareFlowInput.parse(await context.req.json().catch(() => null));
216
- if (input.resourceId !== context.req.param("flowId")) {
217
- throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
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
- app.post("/flows/:flowId/grants/:grantId/revoke", async (context) => {
222
- const auth = requireCapability(context, "flows", "share");
223
- const input = RevokeFlowGrantInput.parse(await context.req.json().catch(() => null));
224
- if (input.resourceId !== context.req.param("flowId") ||
225
- input.grantId !== context.req.param("grantId")) {
226
- throw new IntelError(400, "grant_id_mismatch", "Path and body grant IDs differ");
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
- return context.json(await deps.flows.getRun(asFlowActor(auth), context.req.param("runId")));
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
- const canonical = target.kind === "attachment"
23
- ? await deps.content.getBytes(target.contentKey)
24
- : await deps.content.get(target.contentKey);
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
+ }