@anchrd/intel-api 0.13.0 → 0.14.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.
- package/dist/adapters/cloudflare/cloudflare.js +1 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.js +136 -44
- package/dist/adapters/db/db-indexing.js +79 -0
- package/dist/adapters/db/db.js +66 -27
- package/dist/adapters/semantic-index/semantic-index.js +97 -17
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
- package/dist/bundle/bundle.js +23 -2
- package/dist/http/http.js +4 -0
- package/dist/indexing/indexing.js +165 -16
- package/dist/indexing/indexing.types.d.ts +1 -0
- package/dist/mcp/mcp.js +45 -31
- package/dist/nodes/board/board.d.ts +3 -1
- package/dist/nodes/board/board.js +307 -9
- package/dist/nodes/board/board.types.d.ts +7 -0
- package/dist/nodes/nodes.js +146 -14
- package/dist/nodes/nodes.types.d.ts +61 -3
- package/migrations/0009_no_context_policy.sql +15 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +90 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
|
|
1
|
+
import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, MaxBoardTaskOrderLength, } from "@anchrd/intel-contract";
|
|
2
2
|
import { generateKeyBetween } from "fractional-indexing";
|
|
3
3
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
4
4
|
/**
|
|
@@ -147,7 +147,9 @@ export function upgradeStoredBoard(parsed) {
|
|
|
147
147
|
* first `board_task_update` against such a pair writes the SAME task into both entries. The stored
|
|
148
148
|
* board is then two identical tasks rather than two different ones. That is a consequence of
|
|
149
149
|
* addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
|
|
150
|
-
* import is to stop the pair from existing, not to make it survivable.
|
|
150
|
+
* import is to stop the pair from existing, not to make it survivable. A board that already holds
|
|
151
|
+
* one is repaired by `repairTaskIds` below — asked for, never done on the quiet — and the refusal
|
|
152
|
+
* here names it, because "your file is wrong" without a way out is where anchrd/intel#341 started.
|
|
151
153
|
*
|
|
152
154
|
* So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
|
|
153
155
|
* document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
|
|
@@ -165,6 +167,85 @@ export function repeatedBoardId(document) {
|
|
|
165
167
|
}
|
|
166
168
|
return null;
|
|
167
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The two digits a key is extended with when `fractional-indexing` will not read its bounds
|
|
172
|
+
* (anchrd/intel#359).
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ Both are chosen rather than convenient. `V` is the middle of the character class, so an
|
|
175
|
+
* extension leaves room on either side of itself, and it deliberately is not `0`: a fraction ending
|
|
176
|
+
* in zero is exactly what `generateKeyBetween` refuses to read afterwards. `0` is therefore used
|
|
177
|
+
* only where it is the one digit that still fits under an upper bound.
|
|
178
|
+
*/
|
|
179
|
+
const SmallestOrderDigit = "0";
|
|
180
|
+
const MiddleOrderDigit = "V";
|
|
181
|
+
/**
|
|
182
|
+
* A key strictly between two stored ones, spelled with nothing but the character class
|
|
183
|
+
* (anchrd/intel#359).
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ It is the second half of `orderBetween`, reached only when the library refused, and it rests
|
|
186
|
+
* on the one property `BoardTaskOrder` really guarantees and `byOrder` already sorts by: keys are
|
|
187
|
+
* compared as strings.
|
|
188
|
+
*
|
|
189
|
+
* Two cases, and they are the whole alphabet:
|
|
190
|
+
*
|
|
191
|
+
* - `below` shares no prefix with `above`. Then the two already differ at a digit `above` wins, and
|
|
192
|
+
* anything appended to `below` keeps losing at that same digit — so `below + "V"` is between them
|
|
193
|
+
* however long either of them is.
|
|
194
|
+
* - `below` IS a prefix of `above`. Then every key after `below` starts with `below`, and the
|
|
195
|
+
* smallest one that exists is `below + "0"`. If `above` is that key, the character class holds
|
|
196
|
+
* NOTHING in between — `"a0"` and `"a00"` is the pair this ticket is named after — and the answer
|
|
197
|
+
* is `null`, which the callers turn into a refusal rather than a card placed somewhere else.
|
|
198
|
+
*/
|
|
199
|
+
function extendedOrder(below, above) {
|
|
200
|
+
if (above === null)
|
|
201
|
+
return `${below}${MiddleOrderDigit}`;
|
|
202
|
+
if (below >= above)
|
|
203
|
+
return null;
|
|
204
|
+
if (!above.startsWith(below))
|
|
205
|
+
return `${below}${MiddleOrderDigit}`;
|
|
206
|
+
return above === `${below}${SmallestOrderDigit}` ? null : `${below}${SmallestOrderDigit}`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A key strictly between two stored ones, or `null` when no key exists there (anchrd/intel#359).
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ This is where "who decides what a valid order key is" is answered, and the answer is
|
|
212
|
+
* `BoardTaskOrder` — the character class — and NOT `fractional-indexing`. The two disagreed, and
|
|
213
|
+
* the disagreement WAS the bug: a stored board may spell `"0"`, `"a00"`, `"zzz"` or `"A"`, the
|
|
214
|
+
* library reads none of them, and it says so with a bare `Error` rather than an `IntelError` — a
|
|
215
|
+
* `500 internal_error` on an ordinary `board_task_add`, with one task on the board and no drag
|
|
216
|
+
* involved.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ Narrowing the schema to what the library reads was the other way round, and it is the trap
|
|
219
|
+
* #311, #318 and #321 each walked into from a different side: that would be a rule on the STORED
|
|
220
|
+
* document, and a stored board body is parsed in FOUR places — `parseStoredBoard`, `indexing.ts`,
|
|
221
|
+
* `document-links.ts` and the bundle import. A board carrying one such key would stop being
|
|
222
|
+
* drawable, searchable, linkable and movable, all at once, over a value that is server-assigned and
|
|
223
|
+
* that no caller ever sent.
|
|
224
|
+
*
|
|
225
|
+
* ⚠️ So the library is ASKED and not trusted. It is the only thing that knows how to keep keys
|
|
226
|
+
* short and dense, which is what every healthy board lives on, and it stays the first answer. But
|
|
227
|
+
* when it refuses a bound, that is a statement about the bound and not about the board, and the
|
|
228
|
+
* fallback carries on with string comparison alone.
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ `null` is an answer and not a failure, and it is the one thing callers may not paper over. It
|
|
231
|
+
* means the character class holds no key in that interval at all, so the only alternatives are
|
|
232
|
+
* refusing and sliding a card past another one in silence — and the second is the mistake the
|
|
233
|
+
* second review round of anchrd/intel#341 produced.
|
|
234
|
+
*
|
|
235
|
+
* ⚠️ The `catch` drops the library's message on purpose, and this is not the swallowed cause
|
|
236
|
+
* `.claude/review-rules.md` names: nothing here is being reported to anybody. `generateKeyBetween`
|
|
237
|
+
* throws to say "I do not read this bound", which is a QUESTION ANSWERED and the whole reason the
|
|
238
|
+
* second half exists. The refusals a caller actually sees are built below from `null`, a state this
|
|
239
|
+
* function decided, and they name which side is at fault and what to do about it.
|
|
240
|
+
*/
|
|
241
|
+
function orderBetween(below, above) {
|
|
242
|
+
try {
|
|
243
|
+
return generateKeyBetween(below, above);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return extendedOrder(below ?? "", above);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
168
249
|
/**
|
|
169
250
|
* Every board operation, applied to the document rather than to the file (#285).
|
|
170
251
|
*
|
|
@@ -180,9 +261,14 @@ export function repeatedBoardId(document) {
|
|
|
180
261
|
*/
|
|
181
262
|
export function createBoard(deps) {
|
|
182
263
|
function byOrder(left, right) {
|
|
183
|
-
// The id breaks a tie that
|
|
184
|
-
//
|
|
185
|
-
//
|
|
264
|
+
// ⚠️ The id breaks a tie that DOES happen, which is why it is not merely insurance
|
|
265
|
+
// (anchrd/intel#359). A board bundle written elsewhere may name one key twice, and a placement
|
|
266
|
+
// whose bounds are two neighbours the caller named but that are not next to each other can mint
|
|
267
|
+
// one that a card between them already holds — the second is anchrd/intel#380 and is not fixed
|
|
268
|
+
// here. What every placement taking its bounds from the nearest keys ACTUALLY there cannot do
|
|
269
|
+
// is create a repeat: such a key is strictly inside an interval no card occupies. The tie is
|
|
270
|
+
// broken at all because the sort has to be total — an unstable order would make two readers of
|
|
271
|
+
// the same board disagree about which card comes first.
|
|
186
272
|
if (left.order === right.order)
|
|
187
273
|
return left.id < right.id ? -1 : 1;
|
|
188
274
|
return left.order < right.order ? -1 : 1;
|
|
@@ -349,6 +435,91 @@ export function createBoard(deps) {
|
|
|
349
435
|
for (const id of stack)
|
|
350
436
|
visit(id);
|
|
351
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* The key a placement gets, or the refusal that names the way out (anchrd/intel#359).
|
|
440
|
+
*
|
|
441
|
+
* ⚠️ Everything that mints a key for a placement comes through here, so that the two answers
|
|
442
|
+
* `orderBetween` can give — a key, or "no key exists there" — are turned into an `IntelError`
|
|
443
|
+
* once rather than at every call site. What must never come out of it is a bare `Error`: that is
|
|
444
|
+
* `500 internal_error` at the surface, on a board whose keys somebody else wrote.
|
|
445
|
+
*
|
|
446
|
+
* ⚠️ The length is checked on BOTH paths, not only on the extension. `generateKeyBetween` happily
|
|
447
|
+
* answers a 64-character key with a 65-character one, and a key one over
|
|
448
|
+
* `MaxBoardTaskOrderLength` would be stored and then refused by the very next read
|
|
449
|
+
* (`board_unreadable`) — the whole board lost over one added card.
|
|
450
|
+
*
|
|
451
|
+
* ⚠️ The first refusal names the crowded neighbour by its ORDER KEY rather than by its id, the
|
|
452
|
+
* same way the repair's does: the key is what made it the neighbour, and `board_get` answers with
|
|
453
|
+
* both. The second cannot — a key that is already as long as one may be leaves no interval and so
|
|
454
|
+
* no single task to point at — so it names the move instead of the card.
|
|
455
|
+
*/
|
|
456
|
+
function mintedKey(below, above) {
|
|
457
|
+
const key = orderBetween(below, above);
|
|
458
|
+
if (key === null) {
|
|
459
|
+
// ⚠️ Two sentences, because they are two different situations and only one of them can point
|
|
460
|
+
// at a card. Equal bounds mean the two neighbours ARE one place, and naming "the task whose
|
|
461
|
+
// order is a0" would name both of them; either one moving is what opens the place.
|
|
462
|
+
const reason = below === above
|
|
463
|
+
? `The tasks named as neighbours both carry the order key “${below}”, so there is no place between them. Move either of them with board_task_move`
|
|
464
|
+
: `No order key fits ${below === null ? `before “${above}”` : `between “${below}” and “${above}”`}, so this task has nowhere to go. Move the task whose order is “${above}” with board_task_move`;
|
|
465
|
+
throw new IntelError(409, "board_task_order_unreadable", `${reason}, then place this one again.`);
|
|
466
|
+
}
|
|
467
|
+
if (key.length > MaxBoardTaskOrderLength) {
|
|
468
|
+
throw new IntelError(409, "board_task_order_unreadable", "The order key beside this place is already as long as an order key may be, so there is no room left after it. Move the neighbouring task with board_task_move, then place this one again.");
|
|
469
|
+
}
|
|
470
|
+
return key;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The next key that is really ABOVE the one at `index`, rather than the next task in the list.
|
|
474
|
+
*
|
|
475
|
+
* ⚠️ The difference only exists on a board whose keys came out of a file, and there it is the
|
|
476
|
+
* whole point: two cards under one key have no order between them — `BoardTaskOrder` says so and
|
|
477
|
+
* `byOrder` next door assumes it away — so "between them" is not a place, and a run of equal keys
|
|
478
|
+
* is ONE place rather than several. Taking `others[index + 1]` blindly hands `generateKeyBetween`
|
|
479
|
+
* a lower bound that is not below its upper, which is the bare `Error: >= ` this ticket is about.
|
|
480
|
+
*
|
|
481
|
+
* ⚠️ Skipping the run moves nothing: the new card lands after the whole run instead of inside it,
|
|
482
|
+
* and every card that was on the board keeps its key.
|
|
483
|
+
*/
|
|
484
|
+
function nextOrderAbove(others, index) {
|
|
485
|
+
const below = others[index]?.order;
|
|
486
|
+
if (below === undefined)
|
|
487
|
+
return null;
|
|
488
|
+
return others.slice(index + 1).find((task) => task.order > below)?.order ?? null;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The mirror of `nextOrderAbove`: the nearest key really BELOW the one at `index`.
|
|
492
|
+
*
|
|
493
|
+
* ⚠️ It is what a placement that names only a SUCCESSOR has to stand on, and forgetting it was a
|
|
494
|
+
* bug of its own rather than a detail of anchrd/intel#359. A drop at the top of a column names no
|
|
495
|
+
* predecessor (`board-kanban.ts` sends `rest[landing - 1] ?? null`), and answering that with "no
|
|
496
|
+
* lower bound at all" hands `generateKeyBetween` the whole key space: `(null, "a1")` mints `"a0"`
|
|
497
|
+
* — a key the card below may already hold, in this column or another. Intel then writes two tasks
|
|
498
|
+
* under one order key on an ordinary drag, which is the very state anchrd/intel#341 and this
|
|
499
|
+
* ticket exist to answer.
|
|
500
|
+
*
|
|
501
|
+
* ⚠️ Without it the extension is worse than wrong, it is silent: `extendedOrder("", above)` sees
|
|
502
|
+
* `above.startsWith("")` and answers `"0"`, the smallest key the character class can spell, so a
|
|
503
|
+
* card dropped above one task lands above NONE of them and nothing says so.
|
|
504
|
+
*
|
|
505
|
+
* ⚠️ Against a run of equal keys it answers the key below the WHOLE run, so the new card lands
|
|
506
|
+
* before all of them — the exact mirror of what `nextOrderAbove` does upward, and for the same
|
|
507
|
+
* reason: cards sharing a key have no order between them, so the run is one place.
|
|
508
|
+
*
|
|
509
|
+
* ⚠️ It answers about the bound the caller did NOT name. Where they named both and the two are
|
|
510
|
+
* not next to each other, the bounds are their cards and a key between them can be one that a
|
|
511
|
+
* card in the gap already holds — anchrd/intel#380, not closed here.
|
|
512
|
+
*/
|
|
513
|
+
function previousOrderBelow(others, index) {
|
|
514
|
+
const above = others[index]?.order;
|
|
515
|
+
if (above === undefined)
|
|
516
|
+
return null;
|
|
517
|
+
// `reverse` mutates, and the slice it mutates is this call's own copy.
|
|
518
|
+
return (others
|
|
519
|
+
.slice(0, index)
|
|
520
|
+
.reverse()
|
|
521
|
+
.find((task) => task.order < above)?.order ?? null);
|
|
522
|
+
}
|
|
352
523
|
/**
|
|
353
524
|
* The order key for a task's new place.
|
|
354
525
|
*
|
|
@@ -378,16 +549,55 @@ export function createBoard(deps) {
|
|
|
378
549
|
const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
|
|
379
550
|
const last = column.at(-1);
|
|
380
551
|
if (!last)
|
|
381
|
-
return
|
|
552
|
+
return mintedKey(others.at(-1)?.order ?? null, null);
|
|
382
553
|
afterIndex = others.indexOf(last);
|
|
383
554
|
}
|
|
384
|
-
|
|
555
|
+
// Each bound is the named neighbour where the caller gave one, and the nearest key really on
|
|
556
|
+
// the other side of the other neighbour where they did not. Reading an absent bound as "the end
|
|
557
|
+
// of the key space" is what let a drop at the top of a column mint a key another card holds.
|
|
558
|
+
const lower = afterIndex !== null
|
|
559
|
+
? (others[afterIndex]?.order ?? null)
|
|
560
|
+
: beforeIndex !== null
|
|
561
|
+
? previousOrderBelow(others, beforeIndex)
|
|
562
|
+
: null;
|
|
385
563
|
const upper = beforeIndex !== null
|
|
386
564
|
? (others[beforeIndex]?.order ?? null)
|
|
387
565
|
: afterIndex !== null
|
|
388
|
-
? (others
|
|
566
|
+
? nextOrderAbove(others, afterIndex)
|
|
389
567
|
: null;
|
|
390
|
-
return
|
|
568
|
+
return mintedKey(lower, upper);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A key strictly after `below` and strictly under `above`, for the repair (anchrd/intel#341).
|
|
572
|
+
*
|
|
573
|
+
* ⚠️ The same `orderBetween` the placements use, because there is one answer to "how is an order
|
|
574
|
+
* key made" and it should not be given twice — this only says it in the repair's own words. What
|
|
575
|
+
* differs is the sentence a caller reads: they came here from a refused import, not from a drag.
|
|
576
|
+
*
|
|
577
|
+
* ⚠️ Refused rather than written, in both directions. Over `MaxBoardTaskOrderLength` the key would
|
|
578
|
+
* be stored and then rejected by the very next read (`board_unreadable`); with no key in the
|
|
579
|
+
* interval at all it would take a silent slide past the task above to carry on. Either way the
|
|
580
|
+
* board keeps its pair and hears why.
|
|
581
|
+
*
|
|
582
|
+
* ⚠️ And the refusal says what to DO, for the same reason the refused import does: the crowded
|
|
583
|
+
* neighbour is usually one `board_task_move` from being out of the way, and the repair then goes
|
|
584
|
+
* through. Since anchrd/intel#359 that move is a real way out rather than a second 500 — it meets
|
|
585
|
+
* the same unreadable keys and answers them, which is what closed the one dead end this exit did
|
|
586
|
+
* not reach.
|
|
587
|
+
*
|
|
588
|
+
* ⚠️ Nothing checks the key against the ones the board already holds, and nothing needs to:
|
|
589
|
+
* `above` is the SMALLEST key above `below`, so every other key on the board is at or below
|
|
590
|
+
* `below` or at or above `above`, and `orderBetween` answers strictly inside that interval.
|
|
591
|
+
*/
|
|
592
|
+
function keyAfter(below, above) {
|
|
593
|
+
const key = orderBetween(below, above);
|
|
594
|
+
if (key === null) {
|
|
595
|
+
throw new IntelError(409, "board_task_order_unreadable", `Nothing fits between the repeated task and the one ordered after it. Move the task whose order is “${above}” with board_task_move, then repair again.`);
|
|
596
|
+
}
|
|
597
|
+
if (key.length > MaxBoardTaskOrderLength) {
|
|
598
|
+
throw new IntelError(409, "board_task_order_unreadable", "The order key beside the repeated task is already as long as an order key may be, so a repair has nowhere to put the second entry");
|
|
599
|
+
}
|
|
600
|
+
return key;
|
|
391
601
|
}
|
|
392
602
|
function replaced(board, task) {
|
|
393
603
|
return {
|
|
@@ -523,6 +733,94 @@ export function createBoard(deps) {
|
|
|
523
733
|
});
|
|
524
734
|
return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
|
|
525
735
|
},
|
|
736
|
+
/**
|
|
737
|
+
* The way out of a board that names one task id twice (anchrd/intel#341).
|
|
738
|
+
*
|
|
739
|
+
* ⚠️ Nobody chooses which of the two survives, because nothing is lost: both entries stay,
|
|
740
|
+
* whole, and only the id of the later one changes. That is the entire repair, and it is what
|
|
741
|
+
* lets it happen at all — #321 refused to fold such a pair on the READ precisely because a fold
|
|
742
|
+
* throws one of two real tasks away, and the next save of any kind writes that away for good.
|
|
743
|
+
* Here somebody asked, one field of one entry moves, and the answer says exactly which.
|
|
744
|
+
*
|
|
745
|
+
* ⚠️ The FIRST stored entry keeps the id, and that is not a coin toss. Every read here already
|
|
746
|
+
* means that one by it: `taskOf` is a `find`, so `updateTask`, `moveTask` and `deleteTask` have
|
|
747
|
+
* always taken the first entry's content as "the task with this id". Renumbering the first
|
|
748
|
+
* instead would hand the id to a task the server never meant by it, and every `parentId` and
|
|
749
|
+
* `dependsOn` on the board would silently start pointing at a different card.
|
|
750
|
+
*
|
|
751
|
+
* ⚠️ The UI's fold picks a different entry, and that is not a disagreement to repair.
|
|
752
|
+
* `orderedTasks` (packages/ui) folds AFTER sorting, so the card on screen is whichever of the
|
|
753
|
+
* pair would be drawn first — which can be the second stored one. That rule is about a DRAWING
|
|
754
|
+
* and #321 chose it so the card a reader clicks is the one `byId` answers with; this one is
|
|
755
|
+
* about what the id MEANS, and the two only differ for as long as the pair exists. After the
|
|
756
|
+
* repair every id is distinct, both cards are drawn, and their titles say which is which.
|
|
757
|
+
*
|
|
758
|
+
* ⚠️ `parentId` and `dependsOn` are left exactly as they stand, on both entries and on every
|
|
759
|
+
* other task — they are NOT copied onto the renumbered one. Two tasks cannot inherit one edge:
|
|
760
|
+
* a `parentId` is one id, so "both" is not a thing the document can say. What the pointers
|
|
761
|
+
* meant while the pair existed is what they mean now — the entry that kept the id — and the
|
|
762
|
+
* renumbered task therefore comes out with nothing hanging off it. That is visible on the
|
|
763
|
+
* board, and `board_task_move` moves a subtask under it in one call; inventing the edges here
|
|
764
|
+
* would be the silent change this repair exists to avoid.
|
|
765
|
+
*
|
|
766
|
+
* ⚠️ The `order` key is re-minted with the id, and it HAS to be. A duplicate is a copy, so both
|
|
767
|
+
* entries usually carry the same fractional index — harmless while they were one card, and a
|
|
768
|
+
* broken board the moment they are two: `BoardTaskOrder` says a shared key leaves two tasks with
|
|
769
|
+
* no defined order at all, and `byOrder` next door assumes keys are distinct by construction.
|
|
770
|
+
* Both cards are drawn after the repair, so a drop between them sends the pair as its two
|
|
771
|
+
* neighbours and `generateKeyBetween` refuses a lower bound that is not below its upper — an
|
|
772
|
+
* `Error: >= ` rather than an `IntelError`, which is a 500 on an ordinary drag. Re-minting is
|
|
773
|
+
* not content anybody gave up: `order` is server-assigned and a caller can never send one, and
|
|
774
|
+
* the key is minted where the entry already stood.
|
|
775
|
+
*
|
|
776
|
+
* ⚠️ It is minted against the next key STRICTLY above, not against the neighbour. A file that
|
|
777
|
+
* repeats an id can repeat an order key three times over, and anchoring on an equal key would
|
|
778
|
+
* make the way out throw exactly what it exists to prevent.
|
|
779
|
+
*
|
|
780
|
+
* ⚠️ Neither `requireParent` nor `requireDependencies` runs. Such a board arrived through a
|
|
781
|
+
* bundle written elsewhere and may break other rules too — a dependency on a task it does not
|
|
782
|
+
* hold, a chain deeper than `BoardMaxTaskDepth` — and refusing the repair over one of those
|
|
783
|
+
* would leave the board stuck for a reason the caller did not come about. Nothing here can
|
|
784
|
+
* create one of THOSE either: a fresh id has nothing pointing at it, so no cycle can close
|
|
785
|
+
* through the task that changed and no chain grows.
|
|
786
|
+
*
|
|
787
|
+
* ⚠️ There is no sibling for `statuses`, and none is needed: `configure` writes the whole list
|
|
788
|
+
* and `ConfigureBoardInput` has demanded distinct ids since #285, so one `board_configure` with
|
|
789
|
+
* the repeated id spelled differently repairs a duplicate column — with the caller choosing the
|
|
790
|
+
* label, which is a thing only they can know.
|
|
791
|
+
*/
|
|
792
|
+
repairTaskIds(board) {
|
|
793
|
+
const seen = new Set();
|
|
794
|
+
const renumbered = [];
|
|
795
|
+
// Both grow as keys are minted: `generateKeyBetween` is deterministic, so a third entry under
|
|
796
|
+
// one id anchored on the same key as the second would be handed the second's key back. It
|
|
797
|
+
// anchors on the last one minted for that id instead, which is also what keeps the entries in
|
|
798
|
+
// the order the file listed them.
|
|
799
|
+
const orders = board.tasks.map((task) => task.order);
|
|
800
|
+
const lastMinted = new Map();
|
|
801
|
+
const tasks = board.tasks.map((task) => {
|
|
802
|
+
if (!seen.has(task.id)) {
|
|
803
|
+
seen.add(task.id);
|
|
804
|
+
return task;
|
|
805
|
+
}
|
|
806
|
+
const below = lastMinted.get(task.id) ?? task.order;
|
|
807
|
+
const above = orders.filter((order) => order > below).sort();
|
|
808
|
+
const order = keyAfter(below, above.at(0) ?? null);
|
|
809
|
+
orders.push(order);
|
|
810
|
+
lastMinted.set(task.id, order);
|
|
811
|
+
const renamed = { ...task, id: deps.id(), order };
|
|
812
|
+
seen.add(renamed.id);
|
|
813
|
+
renumbered.push({ previousId: task.id, task: renamed });
|
|
814
|
+
return renamed;
|
|
815
|
+
});
|
|
816
|
+
// Refused rather than answered with an empty list: a caller sent here by a refused import
|
|
817
|
+
// learns that this board is not the one holding the pair, and a board that needs nothing does
|
|
818
|
+
// not get a version, an audit event and a reindex for a document that did not change.
|
|
819
|
+
if (renumbered.length === 0) {
|
|
820
|
+
throw new IntelError(409, "board_task_ids_distinct", "This board names every task once, so there is nothing to repair");
|
|
821
|
+
}
|
|
822
|
+
return { board: { statuses: board.statuses, tasks }, renumbered };
|
|
823
|
+
},
|
|
526
824
|
sorted,
|
|
527
825
|
};
|
|
528
826
|
}
|
|
@@ -27,5 +27,12 @@ export interface BoardOperations {
|
|
|
27
27
|
board: BoardDocument;
|
|
28
28
|
deleted: number;
|
|
29
29
|
};
|
|
30
|
+
repairTaskIds(board: BoardDocument): {
|
|
31
|
+
board: BoardDocument;
|
|
32
|
+
renumbered: {
|
|
33
|
+
previousId: string;
|
|
34
|
+
task: BoardTask;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
30
37
|
sorted(tasks: readonly BoardTask[]): BoardTask[];
|
|
31
38
|
}
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -689,6 +689,34 @@ export function createNodes(deps) {
|
|
|
689
689
|
throw new IntelError(500, "board_unreadable", "The written task cannot be read back");
|
|
690
690
|
return task;
|
|
691
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* What the repair renumbered, read back out of the version it wrote (anchrd/intel#341).
|
|
694
|
+
*
|
|
695
|
+
* ⚠️ The pairs come from the audit metadata for the same reason the added task's id does: a
|
|
696
|
+
* replayed key has to answer with what the FIRST attempt actually did, and the document alone
|
|
697
|
+
* cannot say. After the write every id on that board is distinct and none of them says it is new.
|
|
698
|
+
*
|
|
699
|
+
* ⚠️ The metadata carries ids and no titles — audit is metadata, and a title is text somebody
|
|
700
|
+
* wrote (the same line `configureBoard` draws for its status labels). The tasks themselves are
|
|
701
|
+
* read out of the document, so the answer is the board's own truth rather than a copy made at
|
|
702
|
+
* write time.
|
|
703
|
+
*/
|
|
704
|
+
function renumberedTasks(written) {
|
|
705
|
+
const recorded = written.metadata.renumbered;
|
|
706
|
+
if (!Array.isArray(recorded) || recorded.length === 0) {
|
|
707
|
+
throw new IntelError(500, "board_unreadable", "The repair did not record what it renumbered");
|
|
708
|
+
}
|
|
709
|
+
return recorded.map((entry) => {
|
|
710
|
+
const pair = entry;
|
|
711
|
+
const task = typeof pair.taskId === "string"
|
|
712
|
+
? written.document.tasks.find((candidate) => candidate.id === pair.taskId)
|
|
713
|
+
: undefined;
|
|
714
|
+
if (typeof pair.previousId !== "string" || !task) {
|
|
715
|
+
throw new IntelError(500, "board_unreadable", "The repaired task cannot be read back");
|
|
716
|
+
}
|
|
717
|
+
return { previousId: pair.previousId, task };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
692
720
|
/**
|
|
693
721
|
* What the grant just written does not cover: the documents the flows in this folder read that
|
|
694
722
|
* the new principal still cannot.
|
|
@@ -884,11 +912,19 @@ export function createNodes(deps) {
|
|
|
884
912
|
return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
|
|
885
913
|
},
|
|
886
914
|
async create(actor, input) {
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
|
|
915
|
+
// ⚠️ An agent is never filed through the generic path, whatever the installation looks like
|
|
916
|
+
// (#193). The row it produced was an agent by kind and nothing else: no definition, and no
|
|
917
|
+
// Gate Application — so its first run failed with `agent_key_missing` in the runtime, and the
|
|
918
|
+
// repair was a second, easily forgotten step in Gate. `createAgent` makes both in one go and
|
|
919
|
+
// asks Gate BEFORE it writes anything, so a refusal leaves nothing behind.
|
|
920
|
+
//
|
|
921
|
+
// The refusal is here rather than on each surface for the reason every rule in this file is:
|
|
922
|
+
// `POST /nodes` and the `node_create` MCP tool are two doors into one service, and a check at
|
|
923
|
+
// a door is a check somebody adds a third door past. #190 put the runtime gate here and left
|
|
924
|
+
// this case open on purpose — the ticket demanded "exactly as today" where a runtime exists.
|
|
925
|
+
// This is that case, closed.
|
|
926
|
+
if (input.kind === "agent") {
|
|
927
|
+
throw new IntelError(400, "agent_needs_agent_endpoint", "An agent is created with its definition and its principal together — use the agent endpoint (POST /nodes/agents, or the agent_create tool) rather than the generic node create");
|
|
892
928
|
}
|
|
893
929
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
894
930
|
if (existingId)
|
|
@@ -1510,6 +1546,38 @@ export function createNodes(deps) {
|
|
|
1510
1546
|
}
|
|
1511
1547
|
return { node: written.node, version: written.version, deleted };
|
|
1512
1548
|
},
|
|
1549
|
+
/**
|
|
1550
|
+
* The way out of a board that names one task id twice (anchrd/intel#341).
|
|
1551
|
+
*
|
|
1552
|
+
* ⚠️ It goes through `applyToBoard` like the other five, so it is one version against the board
|
|
1553
|
+
* as it stands, it replays on its key, and it loses a race the same way. What it does NOT do is
|
|
1554
|
+
* name a task — see `repairTaskIds` in `board.ts` for why the pair is exactly what cannot be
|
|
1555
|
+
* named, and for which entry keeps the id.
|
|
1556
|
+
*
|
|
1557
|
+
* ⚠️ No `checkBoardTargets`: nothing about `references` or `assignee` moves, so there is no
|
|
1558
|
+
* fresh claim on the tree to check. Re-checking the ones already stored would refuse the repair
|
|
1559
|
+
* over a reference somebody lost access to since the import — a board stuck for a second reason
|
|
1560
|
+
* on the way out of the first.
|
|
1561
|
+
*/
|
|
1562
|
+
async repairBoardTaskIds(actor, input) {
|
|
1563
|
+
const written = await applyToBoard(actor, input, "node.board_task_repair", (document) => {
|
|
1564
|
+
const repaired = board.repairTaskIds(document);
|
|
1565
|
+
return {
|
|
1566
|
+
document: repaired.board,
|
|
1567
|
+
metadata: {
|
|
1568
|
+
renumbered: repaired.renumbered.map((entry) => ({
|
|
1569
|
+
previousId: entry.previousId,
|
|
1570
|
+
taskId: entry.task.id,
|
|
1571
|
+
})),
|
|
1572
|
+
},
|
|
1573
|
+
};
|
|
1574
|
+
});
|
|
1575
|
+
return {
|
|
1576
|
+
node: written.node,
|
|
1577
|
+
version: written.version,
|
|
1578
|
+
renumbered: renumberedTasks(written),
|
|
1579
|
+
};
|
|
1580
|
+
},
|
|
1513
1581
|
async listVersions(actor, nodeId) {
|
|
1514
1582
|
await requireVisible(actor, nodeId);
|
|
1515
1583
|
return { items: await deps.repository.listVersions(nodeId) };
|
|
@@ -1625,6 +1693,26 @@ export function createNodes(deps) {
|
|
|
1625
1693
|
}
|
|
1626
1694
|
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
1627
1695
|
}
|
|
1696
|
+
/**
|
|
1697
|
+
* ⚠️ Both directions, through the same queue every save goes through (anchrd/intel#348). The
|
|
1698
|
+
* pass reads the node's state and does the matching thing: an archived node has its vectors
|
|
1699
|
+
* taken out of the index, a restored one is embedded again from a record that was emptied when
|
|
1700
|
+
* it went. Two calls to one door rather than a purge written out here, because Vectorize is a
|
|
1701
|
+
* second system and a call into it can fail — the queue is the only thing in this repository
|
|
1702
|
+
* that comes back for it, and a deletion nobody retries is a deletion that quietly did not
|
|
1703
|
+
* happen.
|
|
1704
|
+
*
|
|
1705
|
+
* ⚠️ The full-text half is deliberately NOT emptied on the way in, and the asymmetry is the
|
|
1706
|
+
* point rather than an oversight: an FTS row costs storage and is already invisible (every
|
|
1707
|
+
* read joins `nodes` and drops what is archived), while a vector costs a place in a candidate
|
|
1708
|
+
* list Vectorize caps at 100 for the whole installation. The archived board pays with
|
|
1709
|
+
* somebody else's search results.
|
|
1710
|
+
*
|
|
1711
|
+
* A replayed archive never reaches this line — it returned above, on the idempotency key — so
|
|
1712
|
+
* repeating the same call does not ask the index to forget the same names twice.
|
|
1713
|
+
*/
|
|
1714
|
+
if (updated.currentVersionId)
|
|
1715
|
+
await deps.indexing.enqueue(updated.currentVersionId);
|
|
1628
1716
|
return updated;
|
|
1629
1717
|
},
|
|
1630
1718
|
async listGrants(actor, resourceId) {
|
|
@@ -1772,18 +1860,50 @@ export function createNodes(deps) {
|
|
|
1772
1860
|
if (!deps.semantic)
|
|
1773
1861
|
return { items: lexical.slice(0, input.limit) };
|
|
1774
1862
|
try {
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1863
|
+
/**
|
|
1864
|
+
* Every candidate Vectorize will give for one query, scoped or not (anchrd/intel#348).
|
|
1865
|
+
*
|
|
1866
|
+
* ⚠️ A candidate is a CARD since anchrd/intel#301, not a node, so a busy board can take a
|
|
1867
|
+
* large share of these places and push other nodes out before this code ever sees them —
|
|
1868
|
+
* the fold below cannot repair that, it runs on what came back. `limit * 4` was written when
|
|
1869
|
+
* a board was one vector and forty candidates were forty nodes; against one vector per card
|
|
1870
|
+
* it is a list a single board fills on its own. Asking for the ceiling is the one widening
|
|
1871
|
+
* available. Vectorize charges the query and not the depth, so that side is free; the price
|
|
1872
|
+
* is on the D1 side, where `hydrateVisibleCitations` batches 40 pairs per statement and the
|
|
1873
|
+
* ordinary unscoped search therefore goes from one statement to as many as three. Nothing
|
|
1874
|
+
* about the ceiling itself moves: a `limit` of 25 and every scoped search reached 100 before.
|
|
1875
|
+
*
|
|
1876
|
+
* ⚠️ It is a widening and not a fix, and the reason it is not is written down in
|
|
1877
|
+
* `packages/api/CLAUDE.md`: bounding the fan-out per NODE means filtering on a `nodeId`
|
|
1878
|
+
* metadata index, and Cloudflare only puts a vector into such an index when it is upserted
|
|
1879
|
+
* AFTER the index was created — so it would cost every installation a full re-embed of its
|
|
1880
|
+
* tree, plus an operational step no deployment has taken. That is anchrd/intel#356.
|
|
1881
|
+
*
|
|
1882
|
+
* ⚠️ 100 is the port's own clamp too, and it is Vectorize's documented ceiling for a query
|
|
1883
|
+
* that returns neither values nor metadata (50 for one that does). The scoped case has asked
|
|
1884
|
+
* for it since #126, because a scope cuts the candidates AFTERWARDS, in the D1 statement
|
|
1885
|
+
* that re-checks the ACL — a folder of a dozen documents inside a tree of thousands is not
|
|
1886
|
+
* reached by a narrow fan-out, and a starved scope looks like an empty folder.
|
|
1887
|
+
*/
|
|
1888
|
+
const candidates = 100;
|
|
1781
1889
|
const hits = await deps.semantic.search(input.query, candidates);
|
|
1782
|
-
|
|
1890
|
+
/**
|
|
1891
|
+
* The best-scoring chunk of each node, and its score (anchrd/intel#301).
|
|
1892
|
+
*
|
|
1893
|
+
* ⚠️ The best, not the sum and not the first. A board answers once per card whose vector
|
|
1894
|
+
* matched, and a citation names a node — so a board of three hundred mediocre cards must not
|
|
1895
|
+
* out-rank one document that actually answers, and the passage the reader is shown has to be
|
|
1896
|
+
* the card that scored, not the one that happened to come back first.
|
|
1897
|
+
*/
|
|
1898
|
+
const best = new Map();
|
|
1783
1899
|
for (const hit of hits) {
|
|
1784
|
-
|
|
1900
|
+
const current = best.get(hit.nodeId);
|
|
1901
|
+
if (current === undefined || hit.score > current.score) {
|
|
1902
|
+
best.set(hit.nodeId, { chunkKey: hit.chunkKey, score: hit.score });
|
|
1903
|
+
}
|
|
1785
1904
|
}
|
|
1786
|
-
const
|
|
1905
|
+
const semanticScores = new Map([...best].map(([nodeId, winner]) => [nodeId, winner.score]));
|
|
1906
|
+
const semantic = await deps.repository.hydrateVisibleCitations(actor, [...best].map(([nodeId, winner]) => ({ nodeId, chunkKey: winner.chunkKey })), input.scopeId);
|
|
1787
1907
|
return {
|
|
1788
1908
|
items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
|
|
1789
1909
|
};
|
|
@@ -1796,6 +1916,18 @@ export function createNodes(deps) {
|
|
|
1796
1916
|
if (actor.isAdmin !== true) {
|
|
1797
1917
|
throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
|
|
1798
1918
|
}
|
|
1919
|
+
/**
|
|
1920
|
+
* ⚠️ First, and before a single version is enqueued: the record of what the vector index
|
|
1921
|
+
* holds (anchrd/intel#301). Since #301 an indexing pass skips a chunk whose fingerprint has
|
|
1922
|
+
* not moved, which is what keeps a board of three hundred cards from costing three hundred
|
|
1923
|
+
* embeddings per save — and it would equally make `reindex` skip everything, so a vector
|
|
1924
|
+
* index that had been emptied would stay empty while every version was dutifully requeued.
|
|
1925
|
+
* That is the one failure this call exists to prevent, and it is a silent one: the answer
|
|
1926
|
+
* would be a search that finds less and a `queued` count that says all is well.
|
|
1927
|
+
*
|
|
1928
|
+
* The full-text half needs no equivalent because its rows are overwritten, never skipped.
|
|
1929
|
+
*/
|
|
1930
|
+
await deps.repository.invalidateVectors();
|
|
1799
1931
|
let queued = 0;
|
|
1800
1932
|
let after = null;
|
|
1801
1933
|
for (;;) {
|