@anchrd/intel-api 0.12.1 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/db/db-indexing.js +18 -7
- package/dist/adapters/db/db.js +61 -7
- package/dist/adapters/openid/openid.js +51 -7
- package/dist/auth/auth.js +11 -3
- package/dist/auth/auth.types.d.ts +1 -0
- package/dist/bundle/bundle.js +57 -2
- package/dist/http/http.js +49 -1
- package/dist/indexing/indexing.js +38 -3
- package/dist/mcp/mcp.js +89 -1
- package/dist/nodes/board/board.d.ts +15 -0
- package/dist/nodes/board/board.js +359 -0
- package/dist/nodes/board/board.types.d.ts +31 -0
- package/dist/nodes/board/board.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +9 -2
- package/dist/nodes/document-links/document-links.js +22 -5
- package/dist/nodes/nodes.js +308 -2
- package/dist/nodes/nodes.types.d.ts +53 -9
- package/migrations/0016_boards_in_the_tree.sql +80 -0
- package/package.json +3 -2
package/dist/nodes/nodes.js
CHANGED
|
@@ -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.
|
|
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
|
|
31
|
-
|
|
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:
|
|
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" |
|
|
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
|
-
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,10 +43,11 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.10.0",
|
|
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",
|