@anchrd/intel-api 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +44 -3
  2. package/dist/adapters/cloudflare/cloudflare.js +50 -16
  3. package/dist/adapters/cloudflare/cloudflare.types.d.ts +11 -0
  4. package/dist/adapters/content/content.d.ts +1 -1
  5. package/dist/adapters/db/db-flows.js +161 -20
  6. package/dist/adapters/db/db-grants.d.ts +13 -2
  7. package/dist/adapters/db/db-grants.js +25 -8
  8. package/dist/adapters/db/db-indexing.d.ts +2 -2
  9. package/dist/adapters/db/db-indexing.js +26 -19
  10. package/dist/adapters/db/db.d.ts +3 -3
  11. package/dist/adapters/db/db.js +448 -119
  12. package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
  13. package/dist/adapters/gate-applications/gate-applications.js +66 -0
  14. package/dist/adapters/index-queue/index-queue.d.ts +1 -1
  15. package/dist/adapters/index-queue/index-queue.js +2 -2
  16. package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
  17. package/dist/agent-runtime/agent-runtime.d.ts +16 -0
  18. package/dist/agent-runtime/agent-runtime.js +76 -0
  19. package/dist/agent-runtime/agent-runtime.types.d.ts +57 -0
  20. package/dist/bundle/bundle.d.ts +4 -0
  21. package/dist/bundle/bundle.js +1035 -0
  22. package/dist/bundle/bundle.types.d.ts +33 -0
  23. package/dist/bundle/bundle.types.js +1 -0
  24. package/dist/cli/cli.js +10 -1
  25. package/dist/flows/flows.d.ts +8 -8
  26. package/dist/flows/flows.js +158 -42
  27. package/dist/flows/flows.types.d.ts +40 -7
  28. package/dist/http/http.d.ts +1 -0
  29. package/dist/http/http.js +329 -63
  30. package/dist/http/http.types.d.ts +6 -2
  31. package/dist/indexing/indexing.js +14 -2
  32. package/dist/indexing/indexing.types.d.ts +2 -2
  33. package/dist/intel/intel.js +12 -3
  34. package/dist/intel/intel.types.d.ts +6 -2
  35. package/dist/mcp/mcp.js +483 -124
  36. package/dist/mcp/mcp.types.d.ts +11 -2
  37. package/dist/nodes/nodes.d.ts +2 -0
  38. package/dist/nodes/nodes.js +1337 -0
  39. package/dist/nodes/nodes.types.d.ts +314 -0
  40. package/dist/nodes/nodes.types.js +1 -0
  41. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  42. package/migrations/0012_table_snapshots.sql +29 -0
  43. package/migrations/0013_agents_in_the_tree.sql +76 -0
  44. package/migrations/0014_agent_applications.sql +25 -0
  45. package/package.json +3 -2
  46. package/dist/knowledge/knowledge.d.ts +0 -2
  47. package/dist/knowledge/knowledge.js +0 -761
  48. package/dist/knowledge/knowledge.types.d.ts +0 -198
  49. /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
  50. /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
  51. /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @anchrd/intel-api
2
2
 
3
- Intel is a customer-deployed, model-agnostic operating layer for company knowledge and processes. It
3
+ Intel is a customer-deployed, model-agnostic operating layer for company intelligence and processes. It
4
4
  gives people and MCP-capable AI clients the same governed access to three surfaces:
5
5
 
6
- - **Knowledge** — company content with sharing, immutable versions, retrieval, citations and a graph
7
- - **Flows** — versioned, durable processes that reference Knowledge and Tools
6
+ - **Intelligence** — company content as nodes, with sharing, immutable versions, retrieval, citations and a graph
7
+ - **Flows** — versioned, durable processes that reference nodes and Tools
8
8
  - **Tools** — MCP capabilities discovered through a Cloudflare MCP Portal
9
9
 
10
10
  The UI, the HTTP API and the Intel MCP surface call the same application services, so authorization,
@@ -88,6 +88,47 @@ derived from `INTEL_SESSION_SECRET` and kept in the `portal_tokens` table of you
88
88
  credentials stay with the portal and never reach Intel. The Intel audience token is never forwarded
89
89
  to another OAuth resource.
90
90
 
91
+ Where agents are deployed, bind the agent runtime Worker as the service `AGENT` — the reference
92
+ deployment does, as `{ "binding": "AGENT", "service": "intel-agent" }`. The browser never talks to
93
+ that Worker: it asks Intel under `/api/v1/agents/:agentId/*`, and Intel forwards the call over the
94
+ binding with the Gate token of the person who asked, after checking `agents/run` and the agent's own
95
+ resource ACL. So the runtime needs no second hostname, no CORS and no token in the browser bundle,
96
+ and the page stays on one origin with one session cookie. The binding is optional: without it those
97
+ routes answer `503 agent_runtime_not_configured` and nothing else changes. Deploy `@anchrd/intel-agent`
98
+ before an Intel deployment that declares it — Wrangler refuses a binding to a service that does not
99
+ exist yet.
100
+
101
+ ### Creating an agent creates its Gate application
102
+
103
+ An agent acts as its own machine principal, so creating an agent node also creates the Gate
104
+ Application it runs as, and archiving the node switches that Application off (`disabledAt`) rather
105
+ than deleting it. Restoring the node switches it back on — the same principal, with the same ID and
106
+ the same grants, which is the whole reason it is disabled rather than deleted.
107
+
108
+ Both calls are made with **the bearer of the person asking**, not with `GATE_SERVICE_KEY`. Creating
109
+ and switching a machine principal is administrative work in Gate: the caller needs the applications
110
+ permission there on top of `knowledge:create` in Intel, and Gate audits the act under their name. A
111
+ caller without it is refused with `agent_application_forbidden`, and a Gate that does not answer
112
+ with `agent_application_unavailable` — in which case **no agent node is created at all**. There is
113
+ deliberately no half-created agent to repair later; the request is simply repeated.
114
+
115
+ The response to `POST /api/v1/nodes/agents` (and to the `agent_create` MCP tool) is the only place
116
+ in Intel where a credential appears:
117
+
118
+ 1. It carries `applicationKey.key` **once**, in plain text. Gate issues an Application key a single
119
+ time and stores only its hash, and Intel writes it to no table, no R2 object, no audit event and
120
+ no log. Nothing can hand it out a second time — `application_rotate_key` in Gate issues a new one.
121
+ 2. Put it into the agent runtime's `AGENT_APPLICATION_KEYS` secret, keyed by the **agent node ID**
122
+ that comes back as `applicationKey.agentId`, and store it nowhere else. That secret carries the
123
+ whole list, so `wrangler secret put` is given every entry, not only the new one.
124
+ 3. Grant the application what this agent may reach — its Gate roles, and the resource grants on the
125
+ Intel nodes it works with. That step is what makes the agent's reach its own and stays manual.
126
+
127
+ Intel keeps `applicationId` beside the node and returns it on every agent read. It names the
128
+ principal without authenticating it, which is why it may be stored and shown while the key may not.
129
+ An agent whose `applicationId` is `null` has no principal — imported, restored from a bundle, or
130
+ created before this was automatic — and archiving it touches Gate not at all.
131
+
91
132
  The reference Wrangler deployment binds `DB`, `CONTENT`, `INDEXING`, `AI`, `SEARCH`, `FLOWS`, and
92
133
  `ASSETS`. Create `SEARCH` as a 1024-dimension cosine Vectorize index for the default multilingual
93
134
  Workers AI `@cf/baai/bge-m3` embedding adapter. `FLOWS` targets the exported
@@ -1,20 +1,23 @@
1
1
  import { createGateClient } from "@anchrd/gate-sdk";
2
2
  import { ulid } from "ulid";
3
+ import { createAgentRuntimeService } from "../../agent-runtime/agent-runtime.js";
3
4
  import { createBrowserAuth } from "../../auth/auth.js";
5
+ import { createBundle } from "../../bundle/bundle.js";
4
6
  import { createFlows } from "../../flows/flows.js";
5
7
  import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
6
8
  import { createIntel } from "../../intel/intel.js";
7
- import { createKnowledge } from "../../knowledge/knowledge.js";
9
+ import { createNodes } from "../../nodes/nodes.js";
8
10
  import { IntelError } from "../../shared/intel-error/intel-error.js";
9
11
  import { sha256Hex } from "../../shared/sha256/sha256.js";
10
12
  import { createTools } from "../../tools/tools.js";
11
13
  import { createContentStore } from "../content/content.js";
12
- import { createKnowledgeRepository } from "../db/db.js";
14
+ import { createNodeRepository } from "../db/db.js";
13
15
  import { createFlowRepository } from "../db/db-flows.js";
14
- import { createKnowledgeIndexRepository } from "../db/db-indexing.js";
16
+ import { createNodeIndexRepository } from "../db/db-indexing.js";
15
17
  import { createOAuthClientStore } from "../db/db-oauth.js";
16
18
  import { createDocumentConverter } from "../document-converter/document-converter.js";
17
19
  import { createFlowRuntime } from "../flow-runtime/flow-runtime.js";
20
+ import { createGateApplications } from "../gate-applications/gate-applications.js";
18
21
  import { createIndexQueue, IndexMessage } from "../index-queue/index-queue.js";
19
22
  import { createJsonSchemaValidator } from "../json-schema/json-schema.js";
20
23
  import { createOpenId } from "../openid/openid.js";
@@ -57,18 +60,29 @@ export default {
57
60
  assertConfigured(env);
58
61
  const now = () => new Date();
59
62
  const semantic = env.AI && env.SEARCH ? createSemanticIndex({ ai: env.AI, index: env.SEARCH }) : undefined;
60
- // Built before Knowledge because Knowledge has to ask it one question: who calls into a folder
63
+ // Built before the tree because the tree has to ask it one question: who calls into a folder
61
64
  // from outside it. The repository knows no service, so this stays one direction of dependency.
62
65
  const flowRepository = createFlowRepository({ db: env.DB, now });
63
- const knowledge = createKnowledge({
64
- repository: createKnowledgeRepository({ db: env.DB, now }),
65
- content: createContentStore(env.CONTENT),
66
+ const nodeRepository = createNodeRepository({ db: env.DB, now });
67
+ const contentStore = createContentStore(env.CONTENT);
68
+ const nodes = createNodes({
69
+ repository: nodeRepository,
70
+ content: contentStore,
71
+ // ⚠️ Built from `GATE_URL` alone — no service key is handed to it, and none would help. The
72
+ // Applications surface is admin-gated on a real principal, so every call carries the bearer
73
+ // of the person making it, which the tree passes in per operation (#182).
74
+ applications: createGateApplications({
75
+ fetch: globalThis.fetch.bind(globalThis),
76
+ gateUrl: env.GATE_URL,
77
+ }),
78
+ // The binding's presence IS the signal (#190) — no separate configuration option exists.
79
+ agentRuntimeAvailable: () => env.AGENT !== undefined,
66
80
  id: ulid,
67
81
  now,
68
82
  indexing: createIndexQueue(env.INDEXING),
69
83
  semantic,
70
84
  externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
71
- flowKnowledgeReferences: async (who, folderId) => await flowRepository.knowledgeReferences(who, folderId),
85
+ flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
72
86
  hash: async (content) => {
73
87
  const source = typeof content === "string"
74
88
  ? new TextEncoder().encode(content)
@@ -137,12 +151,12 @@ export default {
137
151
  runtime: createFlowRuntime(env.FLOWS),
138
152
  id: ulid,
139
153
  now,
140
- folderAccess: async (actor, folderId) => await knowledge.folderAccess(actor, folderId),
141
- knowledgeChildren: async (actor, folderId, limit) => await knowledge.childrenBounded(actor, { parentId: folderId, limit }),
142
- // Knowledge's own visibility lookup, unchanged on the way through: the relation graph, the
143
- // requirements list and every Knowledge step of every run read this one answer, so none of
154
+ folderAccess: async (actor, folderId) => await nodes.folderAccess(actor, folderId),
155
+ nodeChildren: async (actor, folderId, limit) => await nodes.childrenBounded(actor, { parentId: folderId, limit }),
156
+ // The tree's own visibility lookup, unchanged on the way through: the relation graph, the
157
+ // requirements list and every tree link of every run read this one answer, so none of
144
158
  // them can be kinder than the others.
145
- visibleKnowledge: async (actor, nodeId) => await knowledge.visibleNode(actor, nodeId),
159
+ visibleNodes: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
146
160
  toolFingerprint: async (actor, toolName) => {
147
161
  const catalog = await tools
148
162
  .catalog({ id: actor.id, email: actor.email, canExecute: true })
@@ -155,15 +169,35 @@ export default {
155
169
  baseUrl: env.INTEL_URL,
156
170
  gateUrl: env.GATE_URL,
157
171
  gate,
158
- knowledge,
172
+ nodes,
159
173
  flows,
160
174
  tools,
175
+ bundle: createBundle({
176
+ repository: nodeRepository,
177
+ flows: flowRepository,
178
+ content: contentStore,
179
+ id: ulid,
180
+ now,
181
+ hash: async (content) => {
182
+ const source = typeof content === "string"
183
+ ? new TextEncoder().encode(content)
184
+ : Uint8Array.from(content).buffer;
185
+ return await sha256Hex(crypto, source);
186
+ },
187
+ indexing: createIndexQueue(env.INDEXING),
188
+ }),
189
+ agents: createAgentRuntimeService({
190
+ ...(env.AGENT ? { runtime: env.AGENT } : {}),
191
+ // The tree's own visibility lookup, unchanged: whether somebody may reach an agent is the
192
+ // same question as whether they may see the node, and no second answer is invented here.
193
+ visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
194
+ }),
161
195
  auth,
162
196
  }).fetch(request);
163
197
  },
164
198
  async queue(batch, env) {
165
199
  const indexing = createIndexing({
166
- repository: createKnowledgeIndexRepository(env.DB),
200
+ repository: createNodeIndexRepository(env.DB),
167
201
  content: createContentStore(env.CONTENT),
168
202
  semantic: env.AI && env.SEARCH ? createSemanticIndex({ ai: env.AI, index: env.SEARCH }) : undefined,
169
203
  converter: env.AI ? createDocumentConverter(env.AI) : undefined,
@@ -182,7 +216,7 @@ export default {
182
216
  }
183
217
  catch (error) {
184
218
  if (error instanceof PermanentIndexingError) {
185
- console.warn("Intel discarded a permanently unindexable Knowledge version", {
219
+ console.warn("Intel discarded a permanently unindexable node version", {
186
220
  versionId: parsed.data.versionId,
187
221
  error: error.message,
188
222
  });
@@ -9,6 +9,17 @@ export interface CloudflareEnv {
9
9
  ASSETS?: {
10
10
  fetch(request: Request): Promise<Response>;
11
11
  };
12
+ /**
13
+ * The agent runtime Worker, bound as a service.
14
+ *
15
+ * ⚠️ Optional, and it has to be: a deployment may run Intel without agents, and a hard requirement
16
+ * here would make the whole installation fail to start over a feature nobody uses. Where it is
17
+ * absent the agent routes answer 503 by name; where it is present the browser reaches the runtime
18
+ * through Intel and never across a second origin (#178).
19
+ */
20
+ AGENT?: {
21
+ fetch(request: Request): Promise<Response>;
22
+ };
12
23
  DB: D1Database;
13
24
  CONTENT: R2Bucket;
14
25
  INDEXING: QueueProducer<IndexMessage>;
@@ -1,3 +1,3 @@
1
- import type { ContentStore } from "../../knowledge/knowledge.types.js";
1
+ import type { ContentStore } from "../../nodes/nodes.types.js";
2
2
  import type { R2Bucket } from "./content.types.js";
3
3
  export declare function createContentStore(bucket: R2Bucket): ContentStore;
@@ -1,5 +1,5 @@
1
1
  import { Flow, FlowGraph, FlowRun, FlowVersion, } from "@anchrd/intel-contract";
2
- import { calleeIds } from "../../flows/flows.js";
2
+ import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
3
3
  import { flowCallable, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
4
4
  const flowColumnNames = [
5
5
  "id",
@@ -35,6 +35,11 @@ const runColumns = runColumnNames.join(", ");
35
35
  // splitting the rendered string would break the moment it is reformatted.
36
36
  const qualifiedRunColumns = (alias) => runColumnNames.map((column) => `${alias}.${column}`).join(", ");
37
37
  const qualifiedFlowColumns = (alias) => flowColumnNames.map((column) => `${alias}.${column}`).join(", ");
38
+ // The same four kinds `treeLinkNodes` filters on, rendered for SQL. Derived rather than written out
39
+ // a second time: a fifth link kind added to the contract would otherwise reach every reader of a
40
+ // graph except this one, and the miss would look like an empty result rather than an error — which
41
+ // is precisely how the pre-0008 `knowledge` matcher survived unnoticed (#153).
42
+ const treeLinkKindList = treeLinkKinds.map((kind) => `'${kind}'`).join(", ");
38
43
  function mapFlow(row) {
39
44
  return Flow.parse({
40
45
  id: row.id,
@@ -102,7 +107,7 @@ function mapRun(row) {
102
107
  const idList = "(SELECT value FROM json_each(?))";
103
108
  export function createFlowRepository(deps) {
104
109
  // A flow holds no grant of its own any more (ADR-0004 §2). What reaches it is the folder it is
105
- // filed in, read through the same walk Knowledge uses, so the two can never drift apart. The CTE
110
+ // filed in, read through the same walk the tree uses, so the two can never drift apart. The CTE
106
111
  // stands in front of the statement, so its bindings come before every other one.
107
112
  const readableBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
108
113
  // An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
@@ -128,6 +133,19 @@ export function createFlowRepository(deps) {
128
133
  ORDER BY lower(flow.title), flow.id${bounded ? " LIMIT ?" : ""}`;
129
134
  return {
130
135
  async listVisible(actor, input = {}) {
136
+ // ⚠️ `archivedOnly` overrides the folder rather than narrowing within it: somebody looking
137
+ // for what they archived does not know where it was filed (#113). The visibility predicate
138
+ // is unchanged, so the archive shows no flow the tree would have hidden.
139
+ if (input.archivedOnly === true) {
140
+ const result = await deps.db
141
+ .prepare(`${subtreeCte}
142
+ SELECT ${flowColumns} FROM flows flow
143
+ WHERE ${flowInSubtree} AND flow.archived_at IS NOT NULL
144
+ ORDER BY flow.archived_at DESC, lower(flow.title), flow.id`)
145
+ .bind(...readableBindings(actor), ...flowInSubtreeBindings(actor))
146
+ .all();
147
+ return (result.results ?? []).map(mapFlow);
148
+ }
131
149
  const scope = scopeOf(input.parentId);
132
150
  const result = await deps.db
133
151
  .prepare(visibleFlows(scope.clause, false, input.includeArchived === true))
@@ -192,10 +210,10 @@ export function createFlowRepository(deps) {
192
210
  return "out-of-reach";
193
211
  const row = await deps.db
194
212
  .prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
195
- SELECT id, parent_id FROM knowledge_nodes WHERE id = ?
213
+ SELECT id, parent_id FROM nodes WHERE id = ?
196
214
  UNION
197
215
  SELECT parent.id, parent.parent_id
198
- FROM knowledge_nodes parent
216
+ FROM nodes parent
199
217
  JOIN ancestors child ON child.parent_id = parent.id
200
218
  )
201
219
  SELECT
@@ -224,9 +242,9 @@ export function createFlowRepository(deps) {
224
242
  const result = await deps.db
225
243
  .prepare(`${subtreeCte},
226
244
  scope(id) AS (
227
- SELECT id FROM knowledge_nodes WHERE id = ?
245
+ SELECT id FROM nodes WHERE id = ?
228
246
  UNION
229
- SELECT child.id FROM knowledge_nodes child JOIN scope ON child.parent_id = scope.id
247
+ SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
230
248
  )
231
249
  SELECT DISTINCT ${qualifiedFlowColumns("flow")},
232
250
  CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
@@ -247,38 +265,42 @@ export function createFlowRepository(deps) {
247
265
  hidden: rows.filter((row) => row.visible !== 1).length,
248
266
  };
249
267
  },
250
- // The Knowledge documents the flows in this folder's subtree read, so whoever shares the folder
268
+ // The documents the flows in this folder's subtree read, so whoever shares the folder
251
269
  // can be told what the grant does not cover (ADR-0004 §4).
252
270
  //
253
271
  // ⚠️ `flowInSubtree` again, the very predicate `listVisible` uses: a flow this actor may not see
254
272
  // must not reach the answer even as a number, because what it reads would then be attributed to
255
- // a folder they administer. Whether any of the returned IDs may be *named* is Knowledge's
273
+ // a folder they administer. Whether any of the returned IDs may be *named* is the tree's
256
274
  // question — this one hands back IDs and no titles.
257
275
  //
258
- // The knowledge nodes are collected first and expanded afterwards, so `json_each` is only ever
259
- // handed the `resourceIds` array of a node that has one.
260
- async knowledgeReferences(actor, folderId) {
276
+ // ⚠️ The link kinds, one `resourceId` each the shape migration 0008 left behind. Before #153
277
+ // this asked for a `knowledge` node with a `resourceIds` array, and both had been rewritten away:
278
+ // the query matched nothing, and the warning it feeds went quiet without ever failing. A stored
279
+ // graph from before 0008 still cannot break it — its kind is not in the list, so it is not read
280
+ // at all — and a node whose configuration carries no `resourceId` is dropped rather than passed
281
+ // on as a null the caller would look up.
282
+ async nodeReferences(actor, folderId) {
261
283
  const result = await deps.db
262
284
  .prepare(`${subtreeCte},
263
285
  scope(id) AS (
264
- SELECT id FROM knowledge_nodes WHERE id = ?
286
+ SELECT id FROM nodes WHERE id = ?
265
287
  UNION
266
- SELECT child.id FROM knowledge_nodes child JOIN scope ON child.parent_id = scope.id
288
+ SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
267
289
  ),
268
- referencing(resource_ids) AS (
269
- SELECT json_extract(node.value, '$.configuration.resourceIds')
290
+ referencing(resource_id) AS (
291
+ SELECT json_extract(node.value, '$.configuration.resourceId')
270
292
  FROM flows flow
271
293
  JOIN flow_versions version ON version.id = flow.published_version_id
272
294
  JOIN json_each(version.graph_json, '$.nodes') node
273
- WHERE json_extract(node.value, '$.kind') = 'knowledge'
295
+ WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
274
296
  AND flow.archived_at IS NULL
275
297
  AND flow.parent_id IN (SELECT id FROM scope)
276
298
  AND ${flowInSubtree}
277
299
  )
278
- SELECT DISTINCT reference.value AS resource_id
300
+ SELECT DISTINCT resource_id
279
301
  FROM referencing
280
- JOIN json_each(referencing.resource_ids) reference
281
- ORDER BY reference.value`)
302
+ WHERE resource_id IS NOT NULL
303
+ ORDER BY resource_id`)
282
304
  .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...flowInSubtreeBindings(actor))
283
305
  .all();
284
306
  return (result.results ?? []).map((row) => row.resource_id);
@@ -394,7 +416,7 @@ export function createFlowRepository(deps) {
394
416
  // flow that went in.
395
417
  //
396
418
  // ⚠️ The idempotency row is written only if the UPDATE actually matched — same guard as
397
- // `knowledge.archive` — so a stale `baseUpdatedAt` leaves no key behind that would make the
419
+ // `node.archive` — so a stale `baseUpdatedAt` leaves no key behind that would make the
398
420
  // retry of a *lost* write look like a replay of a successful one.
399
421
  async archiveFlow(input) {
400
422
  try {
@@ -453,6 +475,22 @@ export function createFlowRepository(deps) {
453
475
  async getVersion(versionId) {
454
476
  return (await this.getVersions([versionId]))[0] ?? null;
455
477
  },
478
+ // `graph_json` is deliberately absent from the SELECT: the history is metadata, and reading
479
+ // every graph of a much-edited flow to draw a table would grow with the number of edits.
480
+ async listVersions(flowId) {
481
+ const result = await deps.db
482
+ .prepare(`SELECT id, flow_id, sequence, created_by, created_at
483
+ FROM flow_versions WHERE flow_id = ? ORDER BY sequence`)
484
+ .bind(flowId)
485
+ .all();
486
+ return (result.results ?? []).map((row) => ({
487
+ id: row.id,
488
+ flowId: row.flow_id,
489
+ sequence: row.sequence,
490
+ createdBy: row.created_by,
491
+ createdAt: row.created_at,
492
+ }));
493
+ },
456
494
  async insertVersion(input) {
457
495
  const version = input.version;
458
496
  try {
@@ -530,6 +568,45 @@ export function createFlowRepository(deps) {
530
568
  }
531
569
  return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
532
570
  },
571
+ // The mirror of `publish`, with the same shape of guards: the idempotency row is written only
572
+ // if the UPDATE actually withdrew something, so revoking what was never published leaves no key
573
+ // behind that would make a later retry look like a success.
574
+ async unpublish(input) {
575
+ try {
576
+ await deps.db.batch([
577
+ deps.db
578
+ .prepare(`UPDATE flows SET published_version_id = NULL, updated_at = ?
579
+ WHERE id = ? AND published_version_id IS NOT NULL`)
580
+ .bind(input.occurredAt, input.flowId),
581
+ deps.db
582
+ .prepare(`INSERT INTO idempotency_keys (
583
+ actor_id, operation, idempotency_key, resource_id, created_at
584
+ ) SELECT ?, 'flows.unpublish', ?, ?, ?
585
+ WHERE EXISTS (
586
+ SELECT 1 FROM flows
587
+ WHERE id = ? AND published_version_id IS NULL AND updated_at = ?
588
+ )`)
589
+ .bind(input.actorId, input.idempotencyKey, input.flowId, input.occurredAt, input.flowId, input.occurredAt),
590
+ deps.db
591
+ .prepare(`INSERT INTO audit_events (
592
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
593
+ ) SELECT ?, ?, 'flows.unpublish', 'flow', ?, ?, ?
594
+ WHERE EXISTS (
595
+ SELECT 1 FROM idempotency_keys
596
+ WHERE actor_id = ? AND operation = 'flows.unpublish' AND idempotency_key = ?
597
+ )`)
598
+ .bind(input.auditId, input.actorId, input.flowId,
599
+ // What was withdrawn, so the trail can say which version was live until here.
600
+ JSON.stringify({ versionId: input.versionId }), input.occurredAt, input.actorId, input.idempotencyKey),
601
+ ]);
602
+ }
603
+ catch (error) {
604
+ if (!(await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey))) {
605
+ throw error;
606
+ }
607
+ }
608
+ return ((await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey)) !== null);
609
+ },
533
610
  async insertRun(input) {
534
611
  const run = input.run;
535
612
  try {
@@ -700,6 +777,70 @@ export function createFlowRepository(deps) {
700
777
  currentNodeId: row.current_node_id,
701
778
  }));
702
779
  },
780
+ // One write for the run and everything below it. The recursion gathers only non-terminal
781
+ // descendants, and the root joins the set only while it is itself non-terminal — so a run that
782
+ // turned terminal between the caller's check and this write cancels nothing at all, and the
783
+ // missing idempotency row reports the conflict. `flow_run_steps` is deliberately untouched:
784
+ // cancelling is not a step outcome (#145).
785
+ async cancelRun(input) {
786
+ const nonTerminal = "status NOT IN ('completed', 'failed', 'cancelled')";
787
+ const affected = `WITH RECURSIVE affected(id) AS (
788
+ SELECT id FROM flow_runs WHERE id = ? AND ${nonTerminal}
789
+ UNION
790
+ SELECT child.id FROM flow_runs child
791
+ JOIN affected ON child.parent_run_id = affected.id
792
+ WHERE child.${nonTerminal}
793
+ )`;
794
+ try {
795
+ await deps.db.batch([
796
+ deps.db
797
+ .prepare(`${affected}
798
+ UPDATE flow_runs SET status = 'cancelled', current_node_id = NULL,
799
+ updated_at = ?, completed_at = ?
800
+ WHERE id IN (SELECT id FROM affected)`)
801
+ .bind(input.runId, input.occurredAt, input.occurredAt),
802
+ deps.db
803
+ .prepare(`INSERT INTO idempotency_keys (
804
+ actor_id, operation, idempotency_key, resource_id, created_at
805
+ ) SELECT ?, 'flows.cancel', ?, ?, ?
806
+ WHERE EXISTS (
807
+ SELECT 1 FROM flow_runs WHERE id = ? AND status = 'cancelled' AND updated_at = ?
808
+ )`)
809
+ .bind(input.actorId, input.idempotencyKey, input.runId, input.occurredAt, input.runId, input.occurredAt),
810
+ deps.db
811
+ .prepare(`INSERT INTO audit_events (
812
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
813
+ ) SELECT ?, ?, 'flows.cancel', 'flow-run', ?, ?, ?
814
+ WHERE EXISTS (
815
+ SELECT 1 FROM idempotency_keys
816
+ WHERE actor_id = ? AND operation = 'flows.cancel' AND idempotency_key = ?
817
+ )`)
818
+ .bind(input.auditId, input.actorId, input.runId, JSON.stringify({ flowId: input.flowId }), input.occurredAt, input.actorId, input.idempotencyKey),
819
+ ]);
820
+ }
821
+ catch (error) {
822
+ if (!(await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey))) {
823
+ throw error;
824
+ }
825
+ }
826
+ const replayed = await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey);
827
+ if (replayed !== input.runId)
828
+ return "conflict";
829
+ // What this write ended, read back by the timestamp it stamped: the root and its descendants,
830
+ // narrowed to the rows this very write turned. A child cancelled by an earlier call keeps its
831
+ // earlier timestamp and stays out, so nothing is signalled twice.
832
+ const result = await deps.db
833
+ .prepare(`WITH RECURSIVE affected(id) AS (
834
+ SELECT id FROM flow_runs WHERE id = ?
835
+ UNION
836
+ SELECT child.id FROM flow_runs child JOIN affected ON child.parent_run_id = affected.id
837
+ )
838
+ SELECT id FROM flow_runs
839
+ WHERE id IN (SELECT id FROM affected) AND status = 'cancelled' AND updated_at = ?`)
840
+ .bind(input.runId, input.occurredAt)
841
+ .all();
842
+ return { cancelled: (result.results ?? []).map((row) => row.id) };
843
+ },
703
844
  async advanceRun(input) {
704
845
  const run = input.run;
705
846
  try {
@@ -6,6 +6,17 @@ export interface GrantActor {
6
6
  }
7
7
  export declare const subtreeCte: string;
8
8
  export declare function subtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
9
+ /**
10
+ * One named folder and everything filed beneath it, appended to a statement that already carries
11
+ * `subtreeCte`. It answers "where", never "whether": a statement joins `descendants` *in addition
12
+ * to* `allowed`, so the scope can only take rows away from an answer the actor was already entitled
13
+ * to. Joining it instead of `allowed` would turn a search argument into a permission, which is
14
+ * exactly what ADR-0004 forbids — the caller supplies the id, and a caller is not a grant.
15
+ *
16
+ * ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id` can
17
+ * reach the table and this walks downwards into it.
18
+ */
19
+ export declare const descendantsCte = "descendants(id) AS (\n SELECT scope.id FROM nodes scope WHERE scope.id = ?\n UNION\n SELECT child.id\n FROM nodes child\n JOIN descendants parent ON child.parent_id = parent.id\n )";
9
20
  /**
10
21
  * Both walks in one statement: what the actor may open, and what they may run. Its bindings are
11
22
  * `subtreeBindings(actor, "read", now)` followed by `subtreeBindings(actor, "execute", now)`, in
@@ -20,7 +31,7 @@ export declare const readableOrRunnableCte: string;
20
31
  */
21
32
  export declare const flowCallable = "(\n ? = 1\n OR flow.owner_id = ?\n OR flow.parent_id IN (SELECT id FROM readable)\n OR flow.parent_id IN (SELECT id FROM runnable)\n)";
22
33
  /**
23
- * The point check for one Knowledge node: the node itself and every ancestor above it. Cheaper than
34
+ * The point check for one node: the node itself and every ancestor above it. Cheaper than
24
35
  * the subtree walk and the same answer, because a grant reaches down and never sideways.
25
36
  *
26
37
  * ⚠️ `UNION`, never `UNION ALL`. The service refuses to move a node into its own descendant, but a
@@ -33,7 +44,7 @@ export declare const nodeVerbQuery: string;
33
44
  export declare function nodeVerbBindings(nodeId: string, actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
34
45
  /**
35
46
  * The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
36
- * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a Knowledge node's
47
+ * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
37
48
  * owner keeps theirs — otherwise a flow at the root of the tree would be unreachable by the person
38
49
  * who created it. `UNION` for the same reason as above.
39
50
  */
@@ -26,13 +26,13 @@ function grantBindings(actor, verb, now) {
26
26
  function subtreeWalk(name) {
27
27
  return `${name}(id, parent_id) AS (
28
28
  SELECT seed.id, seed.parent_id
29
- FROM knowledge_nodes seed
29
+ FROM nodes seed
30
30
  WHERE ? = 1
31
31
  OR seed.owner_id = ?
32
32
  OR ${grantExists("seed.id")}
33
33
  UNION
34
34
  SELECT child.id, child.parent_id
35
- FROM knowledge_nodes child
35
+ FROM nodes child
36
36
  JOIN ${name} parent ON child.parent_id = parent.id
37
37
  )`;
38
38
  }
@@ -42,6 +42,23 @@ export const subtreeCte = `WITH RECURSIVE ${subtreeWalk("allowed")}`;
42
42
  export function subtreeBindings(actor, verb, now) {
43
43
  return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
44
44
  }
45
+ /**
46
+ * One named folder and everything filed beneath it, appended to a statement that already carries
47
+ * `subtreeCte`. It answers "where", never "whether": a statement joins `descendants` *in addition
48
+ * to* `allowed`, so the scope can only take rows away from an answer the actor was already entitled
49
+ * to. Joining it instead of `allowed` would turn a search argument into a permission, which is
50
+ * exactly what ADR-0004 forbids — the caller supplies the id, and a caller is not a grant.
51
+ *
52
+ * ⚠️ `UNION`, never `UNION ALL`, for the reason `nodeVerbQuery` gives: a cycle in `parent_id` can
53
+ * reach the table and this walks downwards into it.
54
+ */
55
+ export const descendantsCte = `descendants(id) AS (
56
+ SELECT scope.id FROM nodes scope WHERE scope.id = ?
57
+ UNION
58
+ SELECT child.id
59
+ FROM nodes child
60
+ JOIN descendants parent ON child.parent_id = parent.id
61
+ )`;
45
62
  /**
46
63
  * Both walks in one statement: what the actor may open, and what they may run. Its bindings are
47
64
  * `subtreeBindings(actor, "read", now)` followed by `subtreeBindings(actor, "execute", now)`, in
@@ -62,7 +79,7 @@ export const flowCallable = `(
62
79
  OR flow.parent_id IN (SELECT id FROM runnable)
63
80
  )`;
64
81
  /**
65
- * The point check for one Knowledge node: the node itself and every ancestor above it. Cheaper than
82
+ * The point check for one node: the node itself and every ancestor above it. Cheaper than
66
83
  * the subtree walk and the same answer, because a grant reaches down and never sideways.
67
84
  *
68
85
  * ⚠️ `UNION`, never `UNION ALL`. The service refuses to move a node into its own descendant, but a
@@ -72,10 +89,10 @@ export const flowCallable = `(
72
89
  * row away and the recursion stops on its own.
73
90
  */
74
91
  export const nodeVerbQuery = `WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
75
- SELECT id, parent_id, owner_id FROM knowledge_nodes WHERE id = ?
92
+ SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
76
93
  UNION
77
94
  SELECT parent.id, parent.parent_id, parent.owner_id
78
- FROM knowledge_nodes parent
95
+ FROM nodes parent
79
96
  JOIN ancestors child ON child.parent_id = parent.id
80
97
  )
81
98
  SELECT 1 AS allowed
@@ -89,18 +106,18 @@ export function nodeVerbBindings(nodeId, actor, verb, now) {
89
106
  }
90
107
  /**
91
108
  * The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
92
- * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a Knowledge node's
109
+ * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
93
110
  * owner keeps theirs — otherwise a flow at the root of the tree would be unreachable by the person
94
111
  * who created it. `UNION` for the same reason as above.
95
112
  */
96
113
  export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
97
114
  SELECT folder.id, folder.parent_id
98
- FROM knowledge_nodes folder
115
+ FROM nodes folder
99
116
  JOIN flows flow ON flow.parent_id = folder.id
100
117
  WHERE flow.id = ?
101
118
  UNION
102
119
  SELECT parent.id, parent.parent_id
103
- FROM knowledge_nodes parent
120
+ FROM nodes parent
104
121
  JOIN ancestors child ON child.parent_id = parent.id
105
122
  )
106
123
  SELECT 1 AS allowed
@@ -1,3 +1,3 @@
1
- import type { KnowledgeIndexRepository } from "../../knowledge/knowledge.types.js";
1
+ import type { NodeIndexRepository } from "../../nodes/nodes.types.js";
2
2
  import type { D1Database } from "./db.types.js";
3
- export declare function createKnowledgeIndexRepository(db: D1Database): KnowledgeIndexRepository;
3
+ export declare function createNodeIndexRepository(db: D1Database): NodeIndexRepository;