@anchrd/intel-api 0.6.7 → 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 +148 -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 +431 -118
  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 +324 -61
  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
@@ -1,761 +0,0 @@
1
- import { TableMediaType, } from "@anchrd/intel-contract";
2
- import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
- import { IntelError } from "../shared/intel-error/intel-error.js";
4
- import { documentLinkTargets } from "./document-links/document-links.js";
5
- // `execute` is meaningful only for flows, and only a folder can hold a flow. A verb that cannot
6
- // apply to a node is neither offered on it nor accepted for it (ADR-0004 §2). The answer lives here
7
- // rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
8
- function applicableVerbs(kind) {
9
- return kind === "folder" ? ["read", "write", "execute", "share"] : ["read", "write", "share"];
10
- }
11
- // ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
12
- // `share` on one folder must not learn the titles of flows they may not see, so the ones they may
13
- // see are named and the rest are only counted (ADR-0004 §3, and #17's review).
14
- function callersDetail(callers) {
15
- const named = callers.visible.map((title) => `“${title}”`).join(", ");
16
- const rest = callers.hidden === 0
17
- ? ""
18
- : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
19
- return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
20
- }
21
- // The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
22
- // never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
23
- // `intel/admin` is Gate's to know, so the warning below can be pessimistic and never permissive.
24
- // A grant to an email address is judged as that address, which is what the grant will be attached
25
- // to; a second grant the same person holds under their user ID is not folded in.
26
- function asPrincipalActor(principal) {
27
- if (principal.type === "user")
28
- return { id: principal.id, email: "" };
29
- if (principal.type === "email")
30
- return { id: "", email: principal.email };
31
- return { id: "", email: "" };
32
- }
33
- function decodeBase64(value) {
34
- if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
35
- throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
36
- }
37
- try {
38
- return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
39
- }
40
- catch {
41
- throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
42
- }
43
- }
44
- export function createKnowledge(deps) {
45
- function mergeSearchResults(lexical, semantic, semanticScores, limit) {
46
- const merged = new Map();
47
- for (const citation of lexical) {
48
- merged.set(citation.nodeId, {
49
- citation,
50
- lexicalScore: citation.score,
51
- semanticScore: undefined,
52
- });
53
- }
54
- for (const citation of semantic) {
55
- const current = merged.get(citation.nodeId);
56
- merged.set(citation.nodeId, {
57
- citation: current?.citation ?? citation,
58
- lexicalScore: current?.lexicalScore,
59
- semanticScore: semanticScores.get(citation.nodeId) ?? citation.score,
60
- });
61
- }
62
- return [...merged.values()]
63
- .map(({ citation, lexicalScore, semanticScore }) => {
64
- const score = lexicalScore !== undefined && semanticScore !== undefined
65
- ? lexicalScore * 0.45 + semanticScore * 0.55
66
- : lexicalScore !== undefined
67
- ? lexicalScore * 0.9
68
- : (semanticScore ?? 0) * 0.85;
69
- return {
70
- ...citation,
71
- score: Math.max(0, Math.min(1, score)),
72
- match: lexicalScore !== undefined && semanticScore !== undefined
73
- ? "hybrid"
74
- : lexicalScore !== undefined
75
- ? "lexical"
76
- : "semantic",
77
- };
78
- })
79
- .sort((left, right) => right.score - left.score || right.freshness.localeCompare(left.freshness))
80
- .slice(0, limit);
81
- }
82
- /**
83
- * A table's body: every version's R2 object joined in sequence order (#40).
84
- *
85
- * ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
86
- * holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
87
- * overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
88
- * trade the ticket asks for by name: the write side is the hot path an agent uses on a schedule,
89
- * the read side is a person opening a grid or one indexing pass. The gets are issued together
90
- * rather than in a chain so the cost is k requests, not k round trips; compacting old segments
91
- * into one object is a later ticket, and it can happen without changing anything a caller sees
92
- * because the version rows stay the history either way.
93
- */
94
- async function tableContent(node) {
95
- if (node.currentVersionId === null)
96
- return "";
97
- // ⚠️ One statement for every segment key, never one per segment, and only the keys rather than
98
- // the whole version rows. The number of D1 round trips a table costs must not grow with the
99
- // number of times it has been appended to (#30).
100
- const keys = await deps.repository.listVersionContentKeys(node.id);
101
- const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
102
- if (segments.some((segment) => segment === null)) {
103
- throw new IntelError(500, "content_missing", "Version content is missing");
104
- }
105
- return segments.join("");
106
- }
107
- /**
108
- * The column names, read from the first version alone.
109
- *
110
- * ⚠️ Deliberately not `tableContent`, and deliberately not the version list either. Checking an
111
- * append against the header runs on every append, so it must cost the same on a table of ten rows
112
- * and on one of ten thousand: one statement that returns one key, and one small R2 read. Reading
113
- * every version row to look at the first would be a cost that grows with the history — one query,
114
- * but more of it every time (#30, #40).
115
- */
116
- async function tableHeader(node) {
117
- if (node.currentVersionId === null)
118
- return null;
119
- const key = await deps.repository.firstVersionContentKey(node.id);
120
- if (key === null)
121
- return null;
122
- const body = await deps.content.get(key);
123
- if (body === null)
124
- throw new IntelError(500, "content_missing", "Version content is missing");
125
- return parseCsv(body)[0] ?? null;
126
- }
127
- async function getDocument(node) {
128
- if (node.currentVersionId === null)
129
- return { node, version: null, content: null };
130
- const version = await deps.repository.getVersion(node.currentVersionId);
131
- if (!version)
132
- throw new IntelError(500, "version_missing", "Current version is missing");
133
- if (node.kind === "attachment")
134
- return { node, version, content: null };
135
- // A table answers with the whole CSV, so downloading, citing and reading it over MCP all get
136
- // the same bytes a person sees in the grid — the format is the export (#40).
137
- if (node.kind === "table")
138
- return { node, version, content: await tableContent(node) };
139
- const content = await deps.content.get(version.contentKey);
140
- if (content === null)
141
- throw new IntelError(500, "content_missing", "Version content is missing");
142
- return { node, version, content };
143
- }
144
- async function tableOf(node) {
145
- const [header = [], ...rows] = parseCsv(await tableContent(node));
146
- return { node, columns: header, rows, versionId: node.currentVersionId };
147
- }
148
- /**
149
- * One immutable object holding only what this write adds, plus the version row that orders it.
150
- *
151
- * ⚠️ The R2 object is written before the version row and is deleted again if the row does not
152
- * land, exactly as `save` does it. An orphaned object is invisible; a version row pointing at
153
- * nothing is a table that cannot be read at all.
154
- */
155
- async function writeTableSegment(actor, node, body, idempotencyKey) {
156
- const versionId = deps.id();
157
- const contentKey = `knowledge/${node.id}/versions/${versionId}`;
158
- await deps.content.put(contentKey, body, TableMediaType);
159
- try {
160
- return await deps.repository.appendTableVersion({
161
- version: {
162
- id: versionId,
163
- nodeId: node.id,
164
- contentKey,
165
- mediaType: TableMediaType,
166
- contentHash: await deps.hash(body),
167
- size: new TextEncoder().encode(body).byteLength,
168
- createdBy: actor.id,
169
- createdAt: deps.now().toISOString(),
170
- },
171
- actorId: actor.id,
172
- idempotencyKey,
173
- auditId: deps.id(),
174
- });
175
- }
176
- catch (error) {
177
- await deps.content.delete(contentKey).catch(() => undefined);
178
- throw error;
179
- }
180
- }
181
- async function requireTable(actor, nodeId) {
182
- const node = await requireVisible(actor, nodeId);
183
- if (node.kind !== "table") {
184
- throw new IntelError(409, "not_a_table", "Only tables accept rows");
185
- }
186
- if (!(await deps.repository.can(actor, node.id, "write"))) {
187
- throw new IntelError(403, "knowledge_forbidden", "Table cannot be edited");
188
- }
189
- return node;
190
- }
191
- /**
192
- * What the grant just written does not cover: the documents the flows in this folder read that
193
- * the new principal still cannot.
194
- *
195
- * ⚠️ It warns and never blocks. A Knowledge reference across the folder edge is a possible
196
- * failure, not a way around permissions — that distinction is the whole of ADR-0004 §4, and a
197
- * block here would force everyone using one central policy document to duplicate it.
198
- *
199
- * ⚠️ Which documents may be named is `getVisible`, the lookup every other Knowledge read goes
200
- * through, and never a second rule written for a message: the ones this actor may see are named,
201
- * the rest are counted. That is exactly where #17 and #19 went wrong in review.
202
- *
203
- * The flow side is filtered by the flow list's own predicate as well. It changes nothing today —
204
- * sharing needs `read` on the folder and `read` inherits over the whole subtree, so every flow in
205
- * it is already visible — but the verbs are independent by decision (ADR-0004 §2), and the day
206
- * `share` stops implying `read` this must not be the place that quietly starts leaking.
207
- *
208
- * It does say one thing about the grantee: that a named document is not readable for them. That
209
- * is the consequence of this actor's own decision, on their own folder, at the moment they make
210
- * it, and it is the smallest answer that lets them make it — the alternative is the flow failing
211
- * for someone else next week with nobody able to say why. It never enumerates the principal's
212
- * access in general and never leaves the documents these flows actually name.
213
- */
214
- async function unreadableForPrincipal(actor, folderId, principal) {
215
- const grantee = asPrincipalActor(principal);
216
- const titles = [];
217
- let hidden = 0;
218
- for (const resourceId of await deps.flowKnowledgeReferences(actor, folderId)) {
219
- if (await deps.repository.can(grantee, resourceId, "read"))
220
- continue;
221
- const node = await deps.repository.getVisible(actor, resourceId);
222
- if (node)
223
- titles.push(node.title);
224
- else
225
- hidden += 1;
226
- }
227
- return { titles, hidden };
228
- }
229
- /**
230
- * The graph, brought in line with what the saved document actually says (#41).
231
- *
232
- * A text link is the relationship — there is no second way to make one any more — so the links
233
- * of a document are rewritten every time it is saved: what is no longer written is no longer
234
- * there, and what was added is.
235
- *
236
- * ⚠️ Only targets this actor may see become links. The author can only insert what the picker
237
- * offers them, but `knowledge_save` takes any content over MCP, and an unfiltered write would
238
- * turn the graph into a place where the existence of an unreachable document can be confirmed by
239
- * anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
240
- * side goes through — one rule, not two.
241
- *
242
- * ⚠️ Rows made in the removed dialog are `manual` and are left alone. Saving a document must not
243
- * silently delete a relationship somebody entered before there was another way to enter one.
244
- */
245
- async function reconcileTextLinks(actor, sourceNodeId, mediaType, content) {
246
- const written = documentLinkTargets(mediaType, content).filter((id) => id !== sourceNodeId);
247
- const visible = written.length === 0
248
- ? []
249
- : (await deps.repository.resolveVisibleTitles(actor, written)).map((entry) => entry.nodeId);
250
- await deps.repository.replaceTextLinks({
251
- sourceNodeId,
252
- links: visible.map((targetNodeId) => ({ id: deps.id(), targetNodeId })),
253
- actorId: actor.id,
254
- auditId: deps.id(),
255
- occurredAt: deps.now().toISOString(),
256
- });
257
- }
258
- async function requireVisible(actor, nodeId) {
259
- const node = await deps.repository.getVisible(actor, nodeId);
260
- if (!node)
261
- throw new IntelError(404, "knowledge_not_found", "Knowledge was not found");
262
- return node;
263
- }
264
- async function nextSequence(node) {
265
- if (!node.currentVersionId)
266
- return 1;
267
- const current = await deps.repository.getVersion(node.currentVersionId);
268
- if (!current) {
269
- throw new IntelError(500, "version_missing", "Current Knowledge version is missing");
270
- }
271
- return current.sequence + 1;
272
- }
273
- async function attachment(actor, nodeId) {
274
- const node = await requireVisible(actor, nodeId);
275
- if (node.kind !== "attachment" || !node.currentVersionId) {
276
- throw new IntelError(404, "attachment_not_found", "Attachment was not found");
277
- }
278
- const version = await deps.repository.getVersion(node.currentVersionId);
279
- if (!version)
280
- throw new IntelError(500, "version_missing", "Current version is missing");
281
- return {
282
- node,
283
- version,
284
- resourceUri: `intel://knowledge/${encodeURIComponent(node.id)}/attachment`,
285
- };
286
- }
287
- return {
288
- async list(actor, input) {
289
- return await deps.repository.listVisible(actor, input);
290
- },
291
- // The same level under a bound, for the one caller that draws a bounded picture of it. It goes
292
- // through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
293
- // a different selection (#30).
294
- async childrenBounded(actor, input) {
295
- return await deps.repository.listVisibleBounded(actor, input);
296
- },
297
- async get(actor, nodeId) {
298
- return await getDocument(await requireVisible(actor, nodeId));
299
- },
300
- // Knowledge and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
301
- // it: may this actor file something in that folder. The answer stays here, with the tree and
302
- // its ACLs, rather than being reimplemented on the flow side.
303
- async folderAccess(actor, folderId) {
304
- const folder = await deps.repository.getVisible(actor, folderId);
305
- if (!folder || folder.archivedAt)
306
- return "missing";
307
- if (folder.kind !== "folder")
308
- return "not-a-folder";
309
- return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
310
- },
311
- async create(actor, input) {
312
- const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.create", input.idempotencyKey);
313
- if (existingId)
314
- return await requireVisible(actor, existingId);
315
- if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
316
- throw new IntelError(403, "knowledge_forbidden", "Parent folder cannot be edited");
317
- }
318
- const timestamp = deps.now().toISOString();
319
- return await deps.repository.insertNode({
320
- node: {
321
- id: deps.id(),
322
- parentId: input.parentId,
323
- kind: input.kind,
324
- title: input.title,
325
- description: input.description,
326
- ownerId: actor.id,
327
- currentVersionId: null,
328
- createdAt: timestamp,
329
- updatedAt: timestamp,
330
- archivedAt: null,
331
- },
332
- actorId: actor.id,
333
- idempotencyKey: input.idempotencyKey,
334
- auditId: deps.id(),
335
- });
336
- },
337
- async save(actor, input) {
338
- const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.save", input.idempotencyKey);
339
- if (existingId) {
340
- const document = await getDocument(await requireVisible(actor, input.nodeId));
341
- await deps.indexing.enqueue(existingId);
342
- return document;
343
- }
344
- const node = await requireVisible(actor, input.nodeId);
345
- if (node.kind !== "document") {
346
- throw new IntelError(409, "document_content_required", "Only documents accept editor content versions");
347
- }
348
- if (!(await deps.repository.can(actor, node.id, "write"))) {
349
- throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
350
- }
351
- if (node.currentVersionId !== input.baseVersionId) {
352
- throw new IntelError(409, "version_conflict", "A newer version already exists");
353
- }
354
- const versionId = deps.id();
355
- const contentKey = `knowledge/${node.id}/versions/${versionId}`;
356
- const createdAt = deps.now().toISOString();
357
- const version = {
358
- id: versionId,
359
- nodeId: node.id,
360
- sequence: await nextSequence(node),
361
- contentKey,
362
- mediaType: input.mediaType,
363
- contentHash: await deps.hash(input.content),
364
- size: new TextEncoder().encode(input.content).byteLength,
365
- createdBy: actor.id,
366
- createdAt,
367
- };
368
- await deps.content.put(contentKey, input.content, input.mediaType);
369
- let saved;
370
- try {
371
- saved = await deps.repository.appendVersion({
372
- version,
373
- actorId: actor.id,
374
- baseVersionId: input.baseVersionId,
375
- idempotencyKey: input.idempotencyKey,
376
- auditId: deps.id(),
377
- });
378
- }
379
- catch (error) {
380
- await deps.content.delete(contentKey).catch(() => undefined);
381
- throw error;
382
- }
383
- if (saved === "conflict") {
384
- await deps.content.delete(contentKey);
385
- const replayed = await deps.repository.findIdempotentNode(actor.id, "knowledge.save", input.idempotencyKey);
386
- if (replayed)
387
- return await getDocument(await requireVisible(actor, node.id));
388
- throw new IntelError(409, "version_conflict", "A newer version already exists");
389
- }
390
- const updated = await requireVisible(actor, node.id);
391
- await reconcileTextLinks(actor, node.id, input.mediaType, input.content);
392
- await deps.indexing.enqueue(version.id);
393
- return { node: updated, version, content: input.content };
394
- },
395
- async saveAttachment(actor, input) {
396
- const existingId = await deps.repository.findIdempotentNode(actor.id, "knowledge.save", input.idempotencyKey);
397
- if (existingId) {
398
- const document = await getDocument(await requireVisible(actor, input.nodeId));
399
- await deps.indexing.enqueue(existingId);
400
- return document;
401
- }
402
- const node = await requireVisible(actor, input.nodeId);
403
- if (node.kind !== "attachment") {
404
- throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
405
- }
406
- if (!(await deps.repository.can(actor, node.id, "write"))) {
407
- throw new IntelError(403, "knowledge_forbidden", "Attachment cannot be edited");
408
- }
409
- if (node.currentVersionId !== input.baseVersionId) {
410
- throw new IntelError(409, "version_conflict", "A newer version already exists");
411
- }
412
- const bytes = decodeBase64(input.contentBase64);
413
- const versionId = deps.id();
414
- const contentKey = `knowledge/${node.id}/versions/${versionId}`;
415
- const createdAt = deps.now().toISOString();
416
- const version = {
417
- id: versionId,
418
- nodeId: node.id,
419
- sequence: await nextSequence(node),
420
- contentKey,
421
- mediaType: input.mediaType,
422
- contentHash: await deps.hash(bytes),
423
- size: bytes.byteLength,
424
- createdBy: actor.id,
425
- createdAt,
426
- };
427
- await deps.content.putBytes(contentKey, bytes.buffer, input.mediaType);
428
- let saved;
429
- try {
430
- saved = await deps.repository.appendVersion({
431
- version,
432
- actorId: actor.id,
433
- baseVersionId: input.baseVersionId,
434
- idempotencyKey: input.idempotencyKey,
435
- auditId: deps.id(),
436
- });
437
- }
438
- catch (error) {
439
- await deps.content.delete(contentKey).catch(() => undefined);
440
- throw error;
441
- }
442
- if (saved === "conflict") {
443
- await deps.content.delete(contentKey);
444
- throw new IntelError(409, "version_conflict", "A newer version already exists");
445
- }
446
- await deps.indexing.enqueue(version.id);
447
- return { node: await requireVisible(actor, node.id), version, content: null };
448
- },
449
- async getAttachment(actor, nodeId) {
450
- return await attachment(actor, nodeId);
451
- },
452
- async readAttachment(actor, nodeId) {
453
- const metadata = await attachment(actor, nodeId);
454
- const body = await deps.content.getStream(metadata.version.contentKey);
455
- if (body === null)
456
- throw new IntelError(500, "content_missing", "Attachment is missing");
457
- return { attachment: metadata, body };
458
- },
459
- async getTable(actor, nodeId) {
460
- const node = await requireVisible(actor, nodeId);
461
- if (node.kind !== "table") {
462
- throw new IntelError(404, "table_not_found", "Table was not found");
463
- }
464
- return await tableOf(node);
465
- },
466
- /**
467
- * Writes the header, once. The columns are the contract every later append is measured against
468
- * (#40), so a second definition is refused rather than merged: a table whose header changed
469
- * would reinterpret every row already appended under the old one, silently and irreversibly.
470
- */
471
- async defineTable(actor, input) {
472
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.append", input.idempotencyKey);
473
- const node = await requireTable(actor, input.nodeId);
474
- if (replayedId)
475
- return await tableOf(node);
476
- if (node.currentVersionId !== null) {
477
- throw new IntelError(409, "table_already_defined", "This table already has a header");
478
- }
479
- const body = encodeCsv([input.columns]);
480
- const version = await writeTableSegment(actor, node, body, input.idempotencyKey);
481
- await deps.indexing.enqueue(version.id);
482
- return await tableOf({ ...node, currentVersionId: version.id });
483
- },
484
- /**
485
- * Rows at the end, and nothing else touched.
486
- *
487
- * ⚠️ No `baseVersionId` and no conflict: each append writes its own immutable object and its
488
- * own version row, so two appends that arrive together both land and neither can overwrite the
489
- * other. This is the whole difference to `save`, which replaces content and therefore has to
490
- * know what it replaces.
491
- */
492
- async appendTableRows(actor, input) {
493
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.append", input.idempotencyKey);
494
- const node = await requireTable(actor, input.nodeId);
495
- if (replayedId) {
496
- const replayed = await deps.repository.getVersion(replayedId);
497
- if (replayed) {
498
- return {
499
- node: await requireVisible(actor, node.id),
500
- version: replayed,
501
- appended: input.rows.length,
502
- };
503
- }
504
- }
505
- const header = await tableHeader(node);
506
- if (header === null) {
507
- throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before appending");
508
- }
509
- // ⚠️ Refused, never padded and never truncated. A row that does not fit the header is a
510
- // caller that believes the table has a different shape, and quietly filling the gap would
511
- // store that misunderstanding as data nobody can tell apart from the real thing afterwards.
512
- const wrong = input.rows.findIndex((row) => row.length !== header.length);
513
- if (wrong !== -1) {
514
- throw new IntelError(400, "table_row_shape", `Row ${wrong + 1} has ${input.rows[wrong]?.length ?? 0} cells but the table has ${header.length} columns: ${header.join(", ")}`);
515
- }
516
- const version = await writeTableSegment(actor, node, encodeCsv(input.rows), input.idempotencyKey);
517
- await deps.indexing.enqueue(version.id);
518
- return {
519
- node: await requireVisible(actor, node.id),
520
- version,
521
- appended: input.rows.length,
522
- };
523
- },
524
- async listVersions(actor, nodeId) {
525
- await requireVisible(actor, nodeId);
526
- return { items: await deps.repository.listVersions(nodeId) };
527
- },
528
- async update(actor, input) {
529
- const current = await requireVisible(actor, input.nodeId);
530
- if (!(await deps.repository.can(actor, current.id, "write"))) {
531
- throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
532
- }
533
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.update", input.idempotencyKey);
534
- if (replayedId)
535
- return await requireVisible(actor, replayedId);
536
- if (input.parentId !== undefined && input.parentId !== null) {
537
- if (input.parentId === current.id) {
538
- throw new IntelError(409, "move_cycle", "Knowledge cannot contain itself");
539
- }
540
- const parent = await requireVisible(actor, input.parentId);
541
- if (parent.kind !== "folder") {
542
- throw new IntelError(409, "parent_not_folder", "Knowledge parent must be a folder");
543
- }
544
- if (!(await deps.repository.can(actor, parent.id, "write"))) {
545
- throw new IntelError(403, "knowledge_forbidden", "Destination folder cannot be edited");
546
- }
547
- }
548
- const updatedAt = deps.now().toISOString();
549
- const updated = await deps.repository.updateNode({
550
- node: {
551
- ...current,
552
- parentId: input.parentId === undefined ? current.parentId : input.parentId,
553
- title: input.title ?? current.title,
554
- description: input.description === undefined ? current.description : input.description,
555
- updatedAt,
556
- },
557
- baseUpdatedAt: input.baseUpdatedAt,
558
- actorId: actor.id,
559
- idempotencyKey: input.idempotencyKey,
560
- auditId: deps.id(),
561
- });
562
- if (updated === "cycle") {
563
- throw new IntelError(409, "move_cycle", "Knowledge cannot be moved into its descendant");
564
- }
565
- if (updated === "conflict") {
566
- throw new IntelError(409, "update_conflict", "Knowledge was changed by another editor");
567
- }
568
- if (updated.currentVersionId)
569
- await deps.indexing.enqueue(updated.currentVersionId);
570
- return updated;
571
- },
572
- async archive(actor, input) {
573
- const current = await requireVisible(actor, input.nodeId);
574
- if (!(await deps.repository.can(actor, current.id, "write"))) {
575
- throw new IntelError(403, "knowledge_forbidden", "Knowledge cannot be edited");
576
- }
577
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.archive", input.idempotencyKey);
578
- if (replayedId)
579
- return await requireVisible(actor, replayedId);
580
- const updatedAt = deps.now().toISOString();
581
- const updated = await deps.repository.archiveNode({
582
- nodeId: current.id,
583
- baseUpdatedAt: input.baseUpdatedAt,
584
- archivedAt: input.archived ? updatedAt : null,
585
- updatedAt,
586
- actorId: actor.id,
587
- idempotencyKey: input.idempotencyKey,
588
- auditId: deps.id(),
589
- });
590
- if (updated === "conflict") {
591
- throw new IntelError(409, "update_conflict", "Knowledge was changed by another editor");
592
- }
593
- return updated;
594
- },
595
- async listGrants(actor, resourceId) {
596
- const node = await requireVisible(actor, resourceId);
597
- if (!(await deps.repository.can(actor, resourceId, "share"))) {
598
- throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
599
- }
600
- return {
601
- resourceId: node.id,
602
- applicableVerbs: applicableVerbs(node.kind),
603
- items: await deps.repository.listGrants(resourceId),
604
- };
605
- },
606
- async listLinks(actor, nodeId) {
607
- await requireVisible(actor, nodeId);
608
- return { items: await deps.repository.listLinksVisible(actor, nodeId) };
609
- },
610
- /**
611
- * The titles of linked documents, for the reader who is looking at the text (#41).
612
- *
613
- * ⚠️ The one place a document link gets a name, and it answers with what this reader may see —
614
- * `getVisible`, the same predicate every other Knowledge read goes through — never with a
615
- * lookup written specially for a label. What is missing from the answer is missing for two
616
- * reasons that must stay indistinguishable: the target is gone, or it is not theirs to see. A
617
- * shape that told them apart would let a document confirm the existence of one they may not
618
- * reach, and that is what the link does not get to say.
619
- *
620
- * ⚠️ Archived targets are absent too. A link to a deleted document has to break visibly rather
621
- * than point quietly at nothing, and "absent" is what the reader's side draws as broken.
622
- */
623
- async resolveLinks(actor, input) {
624
- return { items: await deps.repository.resolveVisibleTitles(actor, input.nodeIds) };
625
- },
626
- async graph(actor, input) {
627
- return await deps.repository.graphVisible(actor, input);
628
- },
629
- // ⚠️ The one shape of this question that answers `null` instead of throwing, and the only one a
630
- // graph may be built from. `get` loads the body and turns a refusal into a 404 the caller has to
631
- // catch; here "you cannot reach it" is a value, so a drawing can leave a node out rather than
632
- // deciding what to do with an exception halfway through (#19).
633
- //
634
- // ⚠️ Visibility and nothing else — deliberately not "and not archived". A run authorizes its
635
- // Knowledge steps through this same answer (#20), and archiving a document must not start
636
- // refusing steps under a message that names the wrong reason. Callers that must not *draw* an
637
- // archived node say so where they draw, the way the callee side already does.
638
- async visibleNode(actor, nodeId) {
639
- return await deps.repository.getVisible(actor, nodeId);
640
- },
641
- async share(actor, input) {
642
- const node = await requireVisible(actor, input.resourceId);
643
- if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
644
- throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
645
- }
646
- if (!applicableVerbs(node.kind).includes(input.verb)) {
647
- throw new IntelError(409, "verb_not_applicable", `A ${node.kind} cannot be granted ${input.verb}`);
648
- }
649
- const replayedId = await deps.repository.findIdempotentNode(actor.id, "knowledge.share", input.idempotencyKey);
650
- const principal = input.principal.type === "email"
651
- ? { type: "email", email: input.principal.email.toLowerCase() }
652
- : input.principal;
653
- if (replayedId) {
654
- const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
655
- // A replay describes the same access as the first attempt did, so the warning is asked
656
- // again rather than remembered: whether a document is readable can have changed since.
657
- if (replayed) {
658
- return {
659
- grant: replayed,
660
- unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
661
- };
662
- }
663
- }
664
- const timestamp = deps.now().toISOString();
665
- const grant = await deps.repository.setGrant({
666
- grant: {
667
- id: deps.id(),
668
- resourceId: input.resourceId,
669
- principal,
670
- verb: input.verb,
671
- expiresAt: input.expiresAt,
672
- createdBy: actor.id,
673
- createdAt: timestamp,
674
- },
675
- actorId: actor.id,
676
- idempotencyKey: input.idempotencyKey,
677
- auditId: deps.id(),
678
- });
679
- // ⚠️ After the grant is written, never before. The answer has to describe the access that is
680
- // now in force — sharing `read` on this folder is exactly what makes the documents inside it
681
- // readable, and a warning computed a moment earlier would name them all.
682
- return { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
683
- },
684
- async revokeGrant(actor, input) {
685
- await requireVisible(actor, input.resourceId);
686
- if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
687
- throw new IntelError(403, "knowledge_forbidden", "Knowledge sharing cannot be managed");
688
- }
689
- const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
690
- if (replayed !== null)
691
- return { revoked: replayed };
692
- // ⚠️ What makes a folder a library is `execute` for the whole organization: that is the one
693
- // grant ADR-0004 §3 lets a flow call across a folder edge for. Taking it away while calls
694
- // reach in from outside would leave those flows published and unrunnable, so the attempt names
695
- // the callers instead of breaking them silently. A grant to one principal beside it narrows
696
- // nothing while the organization-wide one stands, so only that one is guarded — and only when
697
- // no second organization-wide `execute` above it keeps the reach alive, because then this
698
- // revocation narrows nothing either and there is nothing to refuse.
699
- const revoked = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === input.grantId);
700
- if (revoked?.verb === "execute" &&
701
- revoked.principal.type === "organization" &&
702
- !(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
703
- const callers = await deps.externalFlowCallers(actor, input.resourceId);
704
- if (callers.visible.length || callers.hidden) {
705
- throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
706
- }
707
- }
708
- return {
709
- revoked: await deps.repository.revokeGrant({
710
- resourceId: input.resourceId,
711
- grantId: input.grantId,
712
- actorId: actor.id,
713
- idempotencyKey: input.idempotencyKey,
714
- auditId: deps.id(),
715
- occurredAt: deps.now().toISOString(),
716
- }),
717
- };
718
- },
719
- async search(actor, input) {
720
- const lexical = await deps.repository.searchVisible(actor, {
721
- ...input,
722
- limit: Math.min(50, input.limit * 2),
723
- });
724
- if (!deps.semantic)
725
- return { items: lexical.slice(0, input.limit) };
726
- try {
727
- const hits = await deps.semantic.search(input.query, Math.min(100, input.limit * 4));
728
- const semanticScores = new Map();
729
- for (const hit of hits) {
730
- semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
731
- }
732
- const semantic = await deps.repository.hydrateVisibleCitations(actor, [
733
- ...semanticScores.keys(),
734
- ]);
735
- return {
736
- items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
737
- };
738
- }
739
- catch {
740
- return { items: lexical.slice(0, input.limit) };
741
- }
742
- },
743
- async reindex(actor) {
744
- if (actor.isAdmin !== true) {
745
- throw new IntelError(403, "knowledge_reindex_forbidden", "Reindex permission is required");
746
- }
747
- let queued = 0;
748
- let after = null;
749
- for (;;) {
750
- const versionIds = await deps.repository.listCurrentVersionIds({ after, limit: 100 });
751
- for (const versionId of versionIds)
752
- await deps.indexing.enqueue(versionId);
753
- queued += versionIds.length;
754
- if (versionIds.length < 100)
755
- break;
756
- after = versionIds.at(-1) ?? null;
757
- }
758
- return { queued };
759
- },
760
- };
761
- }