@anchrd/intel-api 0.12.0 → 0.12.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,7 +1,8 @@
1
- import { AgentDefinition, AgentMediaType, agentReferenceAccepts, TableMediaType, } from "@anchrd/intel-contract";
1
+ import { AgentDefinition, AgentMediaType, agentReferenceAccepts, BoardDocument, BoardMediaType, TableMediaType, } from "@anchrd/intel-contract";
2
2
  import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
3
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
4
4
  import { plainTitle } from "../shared/plain-title/plain-title.js";
5
+ import { createBoard } from "./board/board.js";
5
6
  import { documentLinkTargets } from "./document-links/document-links.js";
6
7
  // ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
7
8
  // is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
@@ -81,6 +82,9 @@ function decodeBase64(value) {
81
82
  }
82
83
  }
83
84
  export function createNodes(deps) {
85
+ // Made here rather than passed in: the board's rules need nothing this service does not already
86
+ // have, and a caller who could substitute them could substitute the cycle checks with them.
87
+ const board = createBoard({ id: () => deps.id() });
84
88
  function mergeSearchResults(lexical, semantic, semanticScores, limit) {
85
89
  const merged = new Map();
86
90
  for (const citation of lexical) {
@@ -246,7 +250,7 @@ export function createNodes(deps) {
246
250
  await deps.content.put(contentKey, input.body, TableMediaType);
247
251
  let saved;
248
252
  try {
249
- saved = await deps.repository.appendTableSnapshot({
253
+ saved = await deps.repository.appendSnapshotVersion({
250
254
  version,
251
255
  actorId: input.actor.id,
252
256
  baseVersionId: input.baseVersionId,
@@ -453,6 +457,234 @@ export function createNodes(deps) {
453
457
  }
454
458
  return node;
455
459
  }
460
+ // ── Boards (#285) ──────────────────────────────────────────────────────────────────────────────
461
+ // How often one task operation may meet a board that moved under it before it gives up. Every
462
+ // round is a fresh read and the SAME operation applied again — see `applyToBoard` for why that is
463
+ // the point rather than a retry loop hiding a race.
464
+ const boardWriteAttempts = 5;
465
+ function parseStoredBoard(body) {
466
+ let parsed;
467
+ try {
468
+ parsed = JSON.parse(body);
469
+ }
470
+ catch {
471
+ throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
472
+ }
473
+ const document = BoardDocument.safeParse(parsed);
474
+ // ⚠️ Loud, unlike the tolerant read the share warning makes of an agent definition. A board IS
475
+ // its document: answering with an empty one would show somebody a board with no tasks on it,
476
+ // and the next write would store that as the truth.
477
+ if (!document.success) {
478
+ throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
479
+ }
480
+ return document.data;
481
+ }
482
+ /**
483
+ * The board a node currently holds.
484
+ *
485
+ * ⚠️ A board with no version yet answers with the DEFAULTS rather than with nothing (#285). A
486
+ * fresh board already has its five columns — the same way a fresh table answers with no header
487
+ * instead of refusing — and the first write is what puts them into R2. Nothing has to be created
488
+ * twice for a board to be usable the moment it exists.
489
+ */
490
+ async function boardDocumentOf(node) {
491
+ if (node.currentVersionId === null)
492
+ return board.defaultBoard();
493
+ const version = await deps.repository.getVersion(node.currentVersionId);
494
+ if (!version)
495
+ throw new IntelError(500, "version_missing", "Current version is missing");
496
+ const body = await deps.content.get(version.contentKey);
497
+ if (body === null)
498
+ throw new IntelError(500, "content_missing", "Version content is missing");
499
+ return parseStoredBoard(body);
500
+ }
501
+ async function requireBoard(actor, nodeId) {
502
+ const node = await requireVisible(actor, nodeId);
503
+ if (node.kind !== "board") {
504
+ throw new IntelError(409, "not_a_board", "Only boards accept task operations");
505
+ }
506
+ if (!(await deps.repository.can(actor, node.id, "write"))) {
507
+ throw new IntelError(403, "node_forbidden", "Board cannot be edited");
508
+ }
509
+ return node;
510
+ }
511
+ /**
512
+ * What a task points at outside its own board, checked before anything is written (#285).
513
+ *
514
+ * ⚠️ Here rather than in `board.ts`, because both questions are reads against the tree and its
515
+ * ACL, and the board module is deliberately pure. A reference is refused when this actor cannot
516
+ * see it — with one wording for "there is no such node" and "you may not see it", because telling
517
+ * them apart would confirm the existence of a node somebody has no access to (#41).
518
+ */
519
+ async function checkBoardTargets(actor, task) {
520
+ const references = [...new Set(task.references)];
521
+ if (references.length > 0) {
522
+ const visible = new Set((await deps.repository.resolveVisibleTitles(actor, references)).map((entry) => entry.nodeId));
523
+ const missing = references.filter((reference) => !visible.has(reference));
524
+ if (missing.length > 0) {
525
+ throw new IntelError(422, "board_reference_unknown", `A reference has to name a node you can see, and ${missing.length === 1 ? "one does" : `${missing.length} do`} not`);
526
+ }
527
+ }
528
+ if (task.assignee?.type === "agent") {
529
+ const assignee = await deps.repository.getVisible(actor, task.assignee.nodeId);
530
+ if (assignee?.kind !== "agent") {
531
+ throw new IntelError(422, "board_assignee_unknown", "An agent assignee has to name an agent you can see");
532
+ }
533
+ }
534
+ }
535
+ /**
536
+ * One new board version, or `null` when somebody wrote in between.
537
+ *
538
+ * ⚠️ `segment` is `null`, like a document's and unlike a table's (#135): every version here holds
539
+ * the whole board, so there is nothing for a snapshot to mark off from what came before it.
540
+ *
541
+ * ⚠️ `baseVersionId` is the version this service just read, never one a caller sent. It is the
542
+ * board's optimistic lock and it is entirely internal — see `applyToBoard`.
543
+ *
544
+ * ⚠️ The R2 object is written before the version row and deleted again if the row does not land,
545
+ * the same order `save` keeps: an orphaned object is invisible, while a version row pointing at
546
+ * nothing is a board that cannot be read at all.
547
+ */
548
+ async function writeBoardVersion(input) {
549
+ const body = JSON.stringify(input.document);
550
+ const versionId = deps.id();
551
+ const contentKey = contentKeyFor(input.node.id, versionId);
552
+ const version = {
553
+ id: versionId,
554
+ nodeId: input.node.id,
555
+ sequence: await nextSequence(input.node),
556
+ contentKey,
557
+ mediaType: BoardMediaType,
558
+ contentHash: await deps.hash(body),
559
+ size: new TextEncoder().encode(body).byteLength,
560
+ segment: null,
561
+ createdBy: input.actor.id,
562
+ createdAt: deps.now().toISOString(),
563
+ };
564
+ await deps.content.put(contentKey, body, BoardMediaType);
565
+ let saved;
566
+ try {
567
+ saved = await deps.repository.appendSnapshotVersion({
568
+ version,
569
+ actorId: input.actor.id,
570
+ baseVersionId: input.node.currentVersionId,
571
+ operation: input.operation,
572
+ metadata: input.metadata,
573
+ idempotencyKey: input.idempotencyKey,
574
+ auditId: deps.id(),
575
+ });
576
+ }
577
+ catch (error) {
578
+ await deps.content.delete(contentKey).catch(() => undefined);
579
+ // ⚠️ The same key at the same moment, from the caller's own retry. `idempotency_keys` is keyed
580
+ // by (actor, operation, key), so the second write's row collides and D1 rolls the whole batch
581
+ // back — which is the right outcome and the wrong error. The first write owns the act; this
582
+ // one reports a conflict so the caller answers with it, exactly as `appendTableVersion` does
583
+ // one layer down. Anything else is a real failure and still raises.
584
+ const owned = await deps.repository.findIdempotentNode(input.actor.id, input.operation, input.idempotencyKey);
585
+ if (owned === null)
586
+ throw error;
587
+ return null;
588
+ }
589
+ if (saved === "conflict") {
590
+ await deps.content.delete(contentKey);
591
+ return null;
592
+ }
593
+ // A board's `references` are links in the graph exactly like a document's inline ones, so the
594
+ // same reconciliation runs on the same body (#285, #41).
595
+ await reconcileTextLinks(input.actor, input.node.id, BoardMediaType, body);
596
+ await deps.indexing.enqueue(version.id);
597
+ return version;
598
+ }
599
+ /**
600
+ * One task operation, applied to the board and written as its next version (#285).
601
+ *
602
+ * ⚠️ THIS is why a board has no `baseVersionId` on its inputs. The caller names a task id; the
603
+ * service reads the current board, applies the change to THAT document and writes it against the
604
+ * version it read. When somebody else landed a write in between, the insert refuses, and the
605
+ * answer is not `version_conflict` but another round: read again, apply the same change to the
606
+ * board as it now stands, write again. Two agents moving two different cards therefore both get
607
+ * through, which is the whole difference to a table's positions (`TableRowPosition`) — those
608
+ * cannot survive a rewrite, a task id can.
609
+ *
610
+ * ⚠️ Bounded, and the bound is not decoration. A board somebody is hammering must eventually
611
+ * answer rather than loop; after `boardWriteAttempts` rounds the caller hears the same
612
+ * `version_conflict` any other contended write gives them.
613
+ *
614
+ * ⚠️ A replayed key answers out of the version it wrote AND the audit metadata beside it. The
615
+ * document alone cannot say which task an operation touched or how many a delete removed — it is
616
+ * the state after the write, and a deleted subtree is not in it.
617
+ */
618
+ async function applyToBoard(actor, input, operation, apply) {
619
+ let node = await requireBoard(actor, input.nodeId);
620
+ const replayed = await replayedBoardWrite(actor, node, operation, input.idempotencyKey);
621
+ if (replayed)
622
+ return replayed;
623
+ for (let attempt = 0; attempt < boardWriteAttempts; attempt += 1) {
624
+ const applied = apply(await boardDocumentOf(node));
625
+ const version = await writeBoardVersion({
626
+ actor,
627
+ node,
628
+ document: applied.document,
629
+ metadata: applied.metadata,
630
+ operation,
631
+ idempotencyKey: input.idempotencyKey,
632
+ });
633
+ if (version !== null) {
634
+ return {
635
+ node: await requireVisible(actor, node.id),
636
+ version,
637
+ document: applied.document,
638
+ metadata: applied.metadata,
639
+ };
640
+ }
641
+ node = await requireVisible(actor, input.nodeId);
642
+ /**
643
+ * ⚠️ Asked again after EVERY refused round, not only before the first (#285).
644
+ *
645
+ * A conflict says "somebody wrote in between" and usually that somebody was working on
646
+ * another task — retrying is then exactly right. But it can also have been this very call,
647
+ * arriving twice: a double click, or a client that retried before the first answer came back.
648
+ * Retrying THAT blindly would re-use an idempotency key the winning write already owns, and
649
+ * the second attempt would die on the primary key of `idempotency_keys` — an internal error
650
+ * where the caller was promised a replay. So the key is asked again first, and if it is spent
651
+ * the answer is the write that owns it.
652
+ */
653
+ const raced = await replayedBoardWrite(actor, node, operation, input.idempotencyKey);
654
+ if (raced)
655
+ return raced;
656
+ }
657
+ throw new IntelError(409, "version_conflict", "This board is being written to too quickly");
658
+ }
659
+ // The write this key already made, or `null` while it is unspent. The version, the document it
660
+ // holds and the metadata beside it — all three, or nothing: a half-readable replay would answer
661
+ // with a task the caller never got.
662
+ async function replayedBoardWrite(actor, node, operation, idempotencyKey) {
663
+ const replayedId = await deps.repository.findIdempotentNode(actor.id, operation, idempotencyKey);
664
+ if (replayedId === null)
665
+ return null;
666
+ const version = await deps.repository.getVersion(replayedId);
667
+ if (!version)
668
+ return null;
669
+ const metadata = await deps.repository.findSnapshotMetadata(actor.id, operation, idempotencyKey);
670
+ if (!metadata)
671
+ return null;
672
+ const body = await deps.content.get(version.contentKey);
673
+ if (body === null)
674
+ return null;
675
+ return { node, version, document: parseStoredBoard(body), metadata };
676
+ }
677
+ // The task an operation reports, read back out of the version it wrote. The id comes from the
678
+ // audit metadata, so a replay answers with exactly the task the first attempt created.
679
+ function writtenTask(written) {
680
+ const taskId = written.metadata.taskId;
681
+ const task = typeof taskId === "string"
682
+ ? written.document.tasks.find((candidate) => candidate.id === taskId)
683
+ : undefined;
684
+ if (!task)
685
+ throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
686
+ return task;
687
+ }
456
688
  /**
457
689
  * What the grant just written does not cover: the documents the flows in this folder read that
458
690
  * the new principal still cannot.
@@ -1200,6 +1432,80 @@ export function createNodes(deps) {
1200
1432
  });
1201
1433
  return await tableOf(await requireVisible(actor, node.id));
1202
1434
  },
1435
+ /**
1436
+ * The board, as one document (#285).
1437
+ *
1438
+ * ⚠️ `read` and not `write`: seeing a board is the ordinary node read, and `requireBoard` — with
1439
+ * its `write` check — is for the five operations that change it. A board nobody may edit is
1440
+ * still a board they may look at.
1441
+ */
1442
+ async getBoard(actor, input) {
1443
+ const node = await requireVisible(actor, input.nodeId);
1444
+ if (node.kind !== "board") {
1445
+ throw new IntelError(404, "board_not_found", "Board was not found");
1446
+ }
1447
+ return { node, board: await boardDocumentOf(node), versionId: node.currentVersionId };
1448
+ },
1449
+ async configureBoard(actor, input) {
1450
+ const written = await applyToBoard(actor, input, "node.board_configure", (document) => ({
1451
+ document: board.configure(document, input.statuses),
1452
+ // The ids and not the labels: audit is metadata, and a label is text somebody wrote.
1453
+ metadata: { statuses: input.statuses.map((status) => status.id) },
1454
+ }));
1455
+ return { node: written.node, version: written.version, statuses: written.document.statuses };
1456
+ },
1457
+ async addBoardTask(actor, input) {
1458
+ // Before the document is touched at all: a task that names a node nobody can see is refused
1459
+ // rather than stored, and the refusal costs no write (#285).
1460
+ await checkBoardTargets(actor, { references: input.references, assignee: input.assignee });
1461
+ const written = await applyToBoard(actor, input, "node.board_task_add", (document) => {
1462
+ const added = board.addTask(document, input);
1463
+ return { document: added.board, metadata: { taskId: added.task.id } };
1464
+ });
1465
+ return { node: written.node, version: written.version, task: writtenTask(written) };
1466
+ },
1467
+ async updateBoardTask(actor, input) {
1468
+ await checkBoardTargets(actor, {
1469
+ references: input.references ?? [],
1470
+ assignee: input.assignee ?? null,
1471
+ });
1472
+ const written = await applyToBoard(actor, input, "node.board_task_update", (document) => {
1473
+ const updated = board.updateTask(document, input);
1474
+ return { document: updated.board, metadata: { taskId: updated.task.id } };
1475
+ });
1476
+ return { node: written.node, version: written.version, task: writtenTask(written) };
1477
+ },
1478
+ async moveBoardTask(actor, input) {
1479
+ const written = await applyToBoard(actor, input, "node.board_task_move", (document) => {
1480
+ const moved = board.moveTask(document, input);
1481
+ return {
1482
+ document: moved.board,
1483
+ metadata: { taskId: moved.task.id, status: moved.task.status },
1484
+ };
1485
+ });
1486
+ return { node: written.node, version: written.version, task: writtenTask(written) };
1487
+ },
1488
+ /**
1489
+ * A task and everything under it (#285).
1490
+ *
1491
+ * ⚠️ The count is read back out of the write's own metadata rather than recomputed. A replay
1492
+ * has to answer with what the first attempt actually removed, and by then the descendants are
1493
+ * no longer in the document to be counted.
1494
+ */
1495
+ async deleteBoardTask(actor, input) {
1496
+ const written = await applyToBoard(actor, input, "node.board_task_delete", (document) => {
1497
+ const removed = board.deleteTask(document, input.taskId);
1498
+ return {
1499
+ document: removed.board,
1500
+ metadata: { taskId: input.taskId, deleted: removed.deleted },
1501
+ };
1502
+ });
1503
+ const deleted = written.metadata.deleted;
1504
+ if (typeof deleted !== "number") {
1505
+ throw new IntelError(500, "board_unreadable", "The delete did not record what it removed");
1506
+ }
1507
+ return { node: written.node, version: written.version, deleted };
1508
+ },
1203
1509
  async listVersions(actor, nodeId) {
1204
1510
  await requireVisible(actor, nodeId);
1205
1511
  return { items: await deps.repository.listVersions(nodeId) };
@@ -1,4 +1,4 @@
1
- import type { AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, ListAgentsInput, ListNodesInput, Node, NodeAgent, NodeAttachment, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
1
+ import type { AddBoardTaskInput, AgentKeyRotated, AgentList, AppendTableRowsInput, AppendTableRowsResult, ArchiveNodeInput, BoardTaskResult, ConfigureBoardInput, ConfigureBoardResult, CreateAgentInput, CreatedAgent, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteBoardTaskResult, DeleteTableRowsInput, DeleteTableRowsResult, Flow, FlowVersion, GetAgentInput, GetBoardInput, ListAgentsInput, ListNodesInput, MoveBoardTaskInput, Node, NodeAgent, NodeAttachment, NodeBoard, NodeCitation, NodeDocument, NodeGraph, NodeGraphInput, NodeLink, NodeTable, NodeVersion, RedefineTableInput, ResolveNodeLinksInput, ResolveNodeLinksResult, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, ShareInput, ShareResult, UpdateBoardTaskInput, UpdateNodeInput, UpdateTableRowsInput, UpdateTableRowsResult } from "@anchrd/intel-contract";
2
2
  import type { SemanticIndex } from "../adapters/semantic-index/semantic-index.types.js";
3
3
  export interface Actor {
4
4
  id: string;
@@ -27,12 +27,25 @@ export interface NewTableVersion {
27
27
  idempotencyKey: string;
28
28
  auditId: string;
29
29
  }
30
- export type TableSnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine";
31
- export interface NewTableSnapshot {
30
+ export type SnapshotOperation = "node.table_update" | "node.table_delete" | "node.table_redefine" | "node.board_configure" | "node.board_task_add" | "node.board_task_update" | "node.board_task_move" | "node.board_task_delete";
31
+ /**
32
+ * One version that replaces the readable state, written only against the state it replaces.
33
+ *
34
+ * ⚠️ With `sequence` and with `baseVersionId`, unlike the table append. A snapshot replaces what is
35
+ * read, so it has to know which state it replaces — the same optimistic concurrency the document
36
+ * save uses, enforced in the same statement that inserts the row (#135).
37
+ *
38
+ * ⚠️ A board uses this too, and its `baseVersionId` is NOT the caller's (#285). The caller of a task
39
+ * operation never sends one; the service reads the current version, applies the change by task id
40
+ * and passes what it read. A refusal here therefore means "somebody wrote in between", which the
41
+ * service answers by reading again and re-applying, so two agents moving two different tasks both
42
+ * get through instead of one of them meeting `version_conflict`.
43
+ */
44
+ export interface NewSnapshotVersion {
32
45
  version: NodeVersion;
33
46
  actorId: string;
34
- baseVersionId: string;
35
- operation: TableSnapshotOperation;
47
+ baseVersionId: string | null;
48
+ operation: SnapshotOperation;
36
49
  metadata: Record<string, unknown>;
37
50
  idempotencyKey: string;
38
51
  auditId: string;
@@ -51,8 +64,9 @@ export interface NodeRepository {
51
64
  getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
52
65
  listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
53
66
  can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
54
- findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | TableSnapshotOperation, idempotencyKey: string): Promise<string | null>;
67
+ findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
55
68
  findIdempotentRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
69
+ findSnapshotMetadata(actorId: string, operation: SnapshotOperation, idempotencyKey: string): Promise<Record<string, unknown> | null>;
56
70
  insertNode(input: NewNode): Promise<Node>;
57
71
  agentApplicationId(nodeId: string): Promise<string | null>;
58
72
  /**
@@ -78,7 +92,7 @@ export interface NodeRepository {
78
92
  getVersion(versionId: string): Promise<NodeVersion | null>;
79
93
  appendVersion(input: NewNodeVersion): Promise<"saved" | "conflict">;
80
94
  appendTableVersion(input: NewTableVersion): Promise<NodeVersion>;
81
- appendTableSnapshot(input: NewTableSnapshot): Promise<"saved" | "conflict">;
95
+ appendSnapshotVersion(input: NewSnapshotVersion): Promise<"saved" | "conflict">;
82
96
  listVersionContentKeys(nodeId: string): Promise<string[]>;
83
97
  tableHeaderContentKey(nodeId: string): Promise<string | null>;
84
98
  listVersions(nodeId: string): Promise<NodeVersion[]>;
@@ -325,6 +339,21 @@ export interface NodeService {
325
339
  updateTableRows(actor: Actor, input: UpdateTableRowsInput): Promise<UpdateTableRowsResult>;
326
340
  deleteTableRows(actor: Actor, input: DeleteTableRowsInput): Promise<DeleteTableRowsResult>;
327
341
  redefineTable(actor: Actor, input: RedefineTableInput): Promise<NodeTable>;
342
+ /**
343
+ * A board and the five operations that change it (#285).
344
+ *
345
+ * ⚠️ Not one "save the board" call, deliberately. Every write here names a TASK ID and the server
346
+ * applies it to the document it just read, so two callers touching two different tasks both get
347
+ * through — the same problem `appendTableRows` solves for a table, arrived at from the other side.
348
+ * A whole-document write would put every concurrent editor into `version_conflict` and force each
349
+ * of them to read a board of thousands of tasks before changing one word on one card.
350
+ */
351
+ getBoard(actor: Actor, input: GetBoardInput): Promise<NodeBoard>;
352
+ configureBoard(actor: Actor, input: ConfigureBoardInput): Promise<ConfigureBoardResult>;
353
+ addBoardTask(actor: Actor, input: AddBoardTaskInput): Promise<BoardTaskResult>;
354
+ updateBoardTask(actor: Actor, input: UpdateBoardTaskInput): Promise<BoardTaskResult>;
355
+ moveBoardTask(actor: Actor, input: MoveBoardTaskInput): Promise<BoardTaskResult>;
356
+ deleteBoardTask(actor: Actor, input: DeleteBoardTaskInput): Promise<DeleteBoardTaskResult>;
328
357
  getAgent(actor: Actor, input: GetAgentInput): Promise<NodeAgent>;
329
358
  /**
330
359
  * An agent node, its first definition, and the Gate Application it runs as (#182, D29).
@@ -391,12 +420,27 @@ export interface NodeIndexTarget {
391
420
  description: string | null;
392
421
  contentKeys: string[];
393
422
  mediaType: string;
394
- kind: "document" | "attachment" | "table" | "agent";
423
+ kind: "document" | "attachment" | "table" | "agent" | "board";
395
424
  updatedAt: string;
396
425
  }
426
+ /**
427
+ * One passage of a node, as the full-text index holds it.
428
+ *
429
+ * ⚠️ A list, and most kinds put exactly one in it. A board puts one per TASK (#285): "where do I
430
+ * stand with X" has to find a card, and a board indexed as one blob would answer with the whole
431
+ * board every time — the passage a searcher is shown would be whichever thousand characters the
432
+ * snippet happened to cut, from a task that may have nothing to do with the question.
433
+ *
434
+ * `title` is what the passage is called rather than what the node is called: for a task it is the
435
+ * task's own title, which is the text the FTS table weights highest.
436
+ */
437
+ export interface IndexChunk {
438
+ title: string;
439
+ text: string;
440
+ }
397
441
  export interface NodeIndexRepository {
398
442
  getTarget(versionId: string): Promise<NodeIndexTarget | null>;
399
- replace(target: NodeIndexTarget, content: string): Promise<void>;
443
+ replace(target: NodeIndexTarget, chunks: IndexChunk[]): Promise<void>;
400
444
  markIndexed(versionId: string, occurredAt: string): Promise<void>;
401
445
  markError(versionId: string, message: string, occurredAt: string): Promise<void>;
402
446
  }
@@ -0,0 +1,80 @@
1
+ -- #285: a sixth kind in the shared tree — `board`. It is a node like the other five: same
2
+ -- `parent_id`, same folder grants, same immutable `node_versions` rows, same R2 body (ADR-0004 §1).
3
+ -- Its body happens to be a JSON project board rather than prose, which the schema neither knows nor
4
+ -- needs to: nothing here creates a second content model, only the CHECK has to learn the word.
5
+ --
6
+ -- A board's tasks live INSIDE that one body and get no table of their own. That is the decision the
7
+ -- ticket makes: a board with three hundred tasks is one node in the tree, not three hundred, and
8
+ -- nothing about the tree has to behave differently because a board is filed in it.
9
+ --
10
+ -- SQLite cannot alter a CHECK constraint, so the table is rebuilt — the same rebuild 0005 and 0013
11
+ -- performed for `table` and `agent`, written the same long way round for the same two reasons. Both
12
+ -- detours below are copied from a file that earned them against a real database, not from caution.
13
+ --
14
+ -- ⚠️ First detour: the new table is created under the final name rather than built beside the old
15
+ -- one and renamed over it. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, so the
16
+ -- moment the old `nodes` goes, every row of `node_versions`, `tree_grants` and `flows` pointing at a
17
+ -- node is a foreign-key violation. `defer_foreign_keys` postpones the complaint to COMMIT but does
18
+ -- not withdraw it, and `ALTER TABLE ... RENAME` does not settle it either: a rename puts the name
19
+ -- back, not the rows. Only inserting the nodes again, under the name the children have referenced
20
+ -- all along, does. This is also why the self-reference below reads `REFERENCES nodes(id)` — the
21
+ -- final name — which is lesson 2 of the three recorded in `0009_no_context_policy.sql`.
22
+ --
23
+ -- ⚠️ Second detour: `node_links` is the only child of `nodes` declared ON DELETE CASCADE, so that
24
+ -- same implicit delete does not merely flag its rows, it removes them — the migration would commit
25
+ -- with every relationship between two documents quietly gone. The rows are carried out of the way
26
+ -- first and put back afterwards. That is a rescue, not a decision about the data: nothing is
27
+ -- dropped, rewritten or reinterpreted here.
28
+ --
29
+ -- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
30
+ -- exactly how 0005's first version passed a green suite and then failed against the first database
31
+ -- with content in it, and why the proof for this file is a row count of every referencing table
32
+ -- before and after rather than a migration that merely ran.
33
+ --
34
+ -- `context_policy` is carried over unchanged and still NOT NULL. It is dead for every consumer
35
+ -- (#76) and only D1's refusal to drop a column a CHECK names keeps it here; removing it is #86 and
36
+ -- deliberately not smuggled into this rebuild.
37
+ PRAGMA defer_foreign_keys = TRUE;
38
+
39
+ -- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
40
+ -- from whatever the live table has. Anything enforced here would only be enforced a second time on
41
+ -- the way back in, and a holding table that can reject a row is a holding table that can lose one.
42
+ CREATE TABLE nodes_carry AS SELECT * FROM nodes;
43
+ CREATE TABLE node_links_carry AS SELECT * FROM node_links;
44
+
45
+ DROP TABLE nodes;
46
+
47
+ CREATE TABLE nodes (
48
+ id TEXT PRIMARY KEY NOT NULL,
49
+ parent_id TEXT REFERENCES nodes(id),
50
+ kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'agent', 'board')),
51
+ title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
52
+ description TEXT CHECK (description IS NULL OR length(description) <= 2000),
53
+ context_policy TEXT NOT NULL CHECK (context_policy IN ('pinned', 'relevant', 'explicit')),
54
+ owner_id TEXT NOT NULL,
55
+ current_version_id TEXT,
56
+ created_at TEXT NOT NULL,
57
+ updated_at TEXT NOT NULL,
58
+ archived_at TEXT
59
+ );
60
+
61
+ INSERT INTO nodes (
62
+ id, parent_id, kind, title, description, context_policy, owner_id,
63
+ current_version_id, created_at, updated_at, archived_at
64
+ )
65
+ SELECT
66
+ id, parent_id, kind, title, description, context_policy, owner_id,
67
+ current_version_id, created_at, updated_at, archived_at
68
+ FROM nodes_carry;
69
+
70
+ -- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
71
+ -- migration's: if it did, this puts the rows back; if it did not, each one is already present under
72
+ -- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
73
+ -- held before, which is the only outcome this statement is permitted to have.
74
+ INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
75
+
76
+ DROP TABLE nodes_carry;
77
+ DROP TABLE node_links_carry;
78
+
79
+ CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
80
+ CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -47,6 +47,7 @@
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",
50
+ "fractional-indexing": "^4.0.0",
50
51
  "hono": "^4.12.32",
51
52
  "openid-client": "^6.8.4",
52
53
  "ulid": "^3.0.2",