@anchrd/intel-api 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/adapters/cloudflare/cloudflare.js +1 -68
  2. package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
  3. package/dist/adapters/db/db-flows.js +1 -1
  4. package/dist/adapters/db/db-grants.js +1 -1
  5. package/dist/adapters/db/db-indexing.js +79 -0
  6. package/dist/adapters/db/db.js +81 -139
  7. package/dist/adapters/semantic-index/semantic-index.js +97 -17
  8. package/dist/adapters/semantic-index/semantic-index.types.d.ts +20 -1
  9. package/dist/bundle/bundle.js +42 -134
  10. package/dist/cli/cli.js +3 -9
  11. package/dist/http/http.js +5 -206
  12. package/dist/http/http.types.d.ts +0 -8
  13. package/dist/indexing/indexing.js +133 -55
  14. package/dist/indexing/indexing.types.d.ts +1 -0
  15. package/dist/intel/intel.js +4 -9
  16. package/dist/intel/intel.types.d.ts +0 -6
  17. package/dist/mcp/mcp.js +33 -308
  18. package/dist/mcp/mcp.types.d.ts +2 -7
  19. package/dist/nodes/document-links/document-links.d.ts +6 -8
  20. package/dist/nodes/document-links/document-links.js +8 -31
  21. package/dist/nodes/nodes.js +92 -826
  22. package/dist/nodes/nodes.types.d.ts +57 -158
  23. package/dist/tools/tools.js +37 -148
  24. package/dist/tools/tools.types.d.ts +0 -21
  25. package/migrations/0009_no_context_policy.sql +15 -0
  26. package/migrations/0017_a_vector_per_card.sql +38 -0
  27. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  28. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  29. package/package.json +2 -2
  30. package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
  31. package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -214
  32. package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
  33. package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
  34. package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
  35. package/dist/adapters/gate-applications/gate-applications.js +0 -88
  36. package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
  37. package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
  38. package/dist/agent-costs/agent-costs.d.ts +0 -16
  39. package/dist/agent-costs/agent-costs.js +0 -105
  40. package/dist/agent-costs/agent-costs.types.d.ts +0 -30
  41. package/dist/agent-costs/agent-costs.types.js +0 -1
  42. package/dist/agent-runtime/agent-runtime.d.ts +0 -16
  43. package/dist/agent-runtime/agent-runtime.js +0 -150
  44. package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
  45. package/dist/agent-runtime/agent-runtime.types.js +0 -1
  46. package/dist/model-catalog/model-catalog.d.ts +0 -2
  47. package/dist/model-catalog/model-catalog.js +0 -99
  48. package/dist/model-catalog/model-catalog.types.d.ts +0 -15
  49. package/dist/model-catalog/model-catalog.types.js +0 -1
  50. package/dist/nodes/board/board.d.ts +0 -59
  51. package/dist/nodes/board/board.js +0 -528
  52. package/dist/nodes/board/board.types.d.ts +0 -31
  53. package/dist/nodes/board/board.types.js +0 -1
  54. package/migrations/0013_agents_in_the_tree.sql +0 -76
  55. package/migrations/0014_agent_applications.sql +0 -25
  56. package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
  57. package/migrations/0016_boards_in_the_tree.sql +0 -80
@@ -1,528 +0,0 @@
1
- import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
2
- import { generateKeyBetween } from "fractional-indexing";
3
- import { IntelError } from "../../shared/intel-error/intel-error.js";
4
- /**
5
- * Statuses that answer whether standing in them means finished (anchrd/intel#311).
6
- *
7
- * ⚠️ This is the ONE place the old positional rule survives, and it survives as a data migration
8
- * rather than as a reading. A board written before `terminal` existed has no answer to "which
9
- * columns mean finished", and the honest answer is the one that board already behaved as: the last
10
- * column before `archived`, plus the shelf. Filling `false` everywhere instead would quietly declare
11
- * that nothing on any existing board is done — every finished task would become an open blocker
12
- * overnight. Filling `true` everywhere would be the same lie the other way round.
13
- *
14
- * ⚠️ It touches only what is missing. A status that already carries the flag keeps it, whatever the
15
- * position rule would have said — otherwise the migration would undo somebody's configuration every
16
- * time their board was read.
17
- *
18
- * `undefined` means "nothing to do", which is what keeps an untouched document identical rather
19
- * than merely equal.
20
- */
21
- function upgradedStatuses(statuses) {
22
- if (!Array.isArray(statuses))
23
- return undefined;
24
- const entries = statuses.filter((status) => typeof status === "object" && status !== null);
25
- if (entries.every((status) => "terminal" in status))
26
- return undefined;
27
- // The end of the work as the board behaved before the flag: the last column that is not the shelf,
28
- // read in the stored order rather than in array order, because `order` is what a surface draws by.
29
- const working = entries
30
- .filter((status) => status.id !== ArchivedBoardStatusId)
31
- .sort((left, right) => Number(left.order ?? 0) - Number(right.order ?? 0));
32
- const lastWorking = working.at(-1)?.id;
33
- return statuses.map((status) => {
34
- if (typeof status !== "object" || status === null || "terminal" in status)
35
- return status;
36
- const entry = status;
37
- return {
38
- ...entry,
39
- terminal: entry.id === ArchivedBoardStatusId || entry.id === lastWorking,
40
- };
41
- });
42
- }
43
- /**
44
- * The three task lists that have to be distinct, and how a stored entry is recognised again.
45
- *
46
- * ⚠️ `labels` are compared TRIMMED and the two id lists are not, and that mirrors the schema
47
- * exactly: `BoardTaskLabel` carries `.trim()`, `BoardTaskId` and `IntelId` do not. Comparing raw
48
- * here would leave `["Bug ", "Bug"]` untouched — and zod, which trims each entry BEFORE the
49
- * distinctness check runs on the array, would then see one word twice and refuse the board. That is
50
- * precisely the `board_unreadable` this function exists to prevent, arrived at through the fold.
51
- */
52
- const distinctTaskLists = [
53
- { field: "dependsOn", identity: (value) => value },
54
- {
55
- field: "labels",
56
- identity: (value) => (typeof value === "string" ? value.trim() : value),
57
- },
58
- { field: "references", identity: (value) => value },
59
- ];
60
- /**
61
- * Tasks whose lists name nothing twice (anchrd/intel#318).
62
- *
63
- * ⚠️ `dependsOn`, `labels` and `references` are refused at the write boundary now, and that refusal
64
- * reaches nothing already in R2 — boards have been writable since `c29fd12` and `board_task_update`
65
- * took a repeat without complaint. A stored board carrying one would meet the strict `BoardTask` and
66
- * answer `board_unreadable`: the whole board lost over a value that has no reading, which is the
67
- * trap #311 walked into from the other side.
68
- *
69
- * ⚠️ Folded here and refused there, and the difference is not inconsistency — it is who is being
70
- * answered. A caller handed back a shorter list than it sent learns nothing and repeats itself; a
71
- * stored document has no caller to tell, and dropping the board instead would punish a reader for
72
- * what some writer did months ago.
73
- *
74
- * ⚠️ The FIRST mention keeps its place. Every surface draws these lists in the order they stand in.
75
- */
76
- function dedupedTaskLists(tasks) {
77
- if (!Array.isArray(tasks))
78
- return undefined;
79
- let folded = false;
80
- const next = tasks.map((task) => {
81
- if (typeof task !== "object" || task === null)
82
- return task;
83
- const entry = task;
84
- const lists = distinctTaskLists.flatMap(({ field, identity }) => {
85
- const list = entry[field];
86
- if (!Array.isArray(list))
87
- return [];
88
- const seen = new Set();
89
- const distinct = list.filter((value) => {
90
- const key = identity(value);
91
- if (seen.has(key))
92
- return false;
93
- seen.add(key);
94
- return true;
95
- });
96
- return distinct.length === list.length ? [] : [[field, distinct]];
97
- });
98
- if (lists.length === 0)
99
- return task;
100
- folded = true;
101
- return { ...entry, ...Object.fromEntries(lists) };
102
- });
103
- return folded ? next : undefined;
104
- }
105
- /**
106
- * A stored board brought up to the current schema, before it is validated.
107
- *
108
- * ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
109
- * a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
110
- * (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
111
- *
112
- * The upgrade is persisted by the next write of any kind, because every write serialises the whole
113
- * document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
114
- */
115
- export function upgradeStoredBoard(parsed) {
116
- if (typeof parsed !== "object" || parsed === null)
117
- return parsed;
118
- const document = parsed;
119
- const statuses = upgradedStatuses(document.statuses);
120
- const tasks = dedupedTaskLists(document.tasks);
121
- if (statuses === undefined && tasks === undefined)
122
- return parsed;
123
- return {
124
- ...document,
125
- ...(statuses !== undefined && { statuses }),
126
- ...(tasks !== undefined && { tasks }),
127
- };
128
- }
129
- /**
130
- * The first id a board document uses for two different things (anchrd/intel#321).
131
- *
132
- * ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
133
- * reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
134
- * board imported before this existed would stop answering with `board_unreadable`: the whole board
135
- * gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
136
- * from the other side.
137
- *
138
- * ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
139
- * repeated `dependsOn`, label or reference carries no information, so dropping the second mention
140
- * loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
141
- * fold on the READ is written back by the next save of any kind, because every write serialises the
142
- * whole document. That is one task gone for good, for every board, without anybody having asked.
143
- * The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
144
- * both, and repairing the pair brings the second card back.
145
- *
146
- * ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
147
- * first `board_task_update` against such a pair writes the SAME task into both entries. The stored
148
- * board is then two identical tasks rather than two different ones. That is a consequence of
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.
151
- *
152
- * So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
153
- * document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
154
- * and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
155
- * #285.
156
- */
157
- export function repeatedBoardId(document) {
158
- for (const list of ["statuses", "tasks"]) {
159
- const seen = new Set();
160
- for (const entry of document[list]) {
161
- if (seen.has(entry.id))
162
- return { list, id: entry.id };
163
- seen.add(entry.id);
164
- }
165
- }
166
- return null;
167
- }
168
- /**
169
- * Every board operation, applied to the document rather than to the file (#285).
170
- *
171
- * ⚠️ This module is where a board's rules live, and it is deliberately pure: it takes a document
172
- * and gives back the next one. The version, R2 and the retry against a racing write are the
173
- * service's business (`nodes.ts`), and the surfaces have no rules of their own — HTTP, MCP and the
174
- * UI all arrive here. A second implementation of "may this task depend on that one" is the thing
175
- * this shape exists to prevent.
176
- *
177
- * ⚠️ What it does NOT check is anything that needs the tree: whether a `references` id names a node
178
- * the writer may see, and whether an `agent` assignee is really an agent. Those are reads against
179
- * D1 and the ACL, so they happen in the service before the document is touched at all.
180
- */
181
- export function createBoard(deps) {
182
- function byOrder(left, right) {
183
- // The id breaks a tie that cannot happen: two keys are distinct by construction. It is here so
184
- // the sort is total, because an unstable order would make two readers of the same board
185
- // disagree about which card comes first.
186
- if (left.order === right.order)
187
- return left.id < right.id ? -1 : 1;
188
- return left.order < right.order ? -1 : 1;
189
- }
190
- function sorted(tasks) {
191
- return [...tasks].sort(byOrder);
192
- }
193
- function taskOf(board, taskId) {
194
- const task = board.tasks.find((candidate) => candidate.id === taskId);
195
- if (!task)
196
- throw new IntelError(404, "board_task_not_found", "Task was not found");
197
- return task;
198
- }
199
- function requireStatus(board, statusId) {
200
- if (!board.statuses.some((status) => status.id === statusId)) {
201
- throw new IntelError(422, "board_status_unknown", `This board has no status “${statusId}”`);
202
- }
203
- }
204
- // The column a task lands in when nobody says otherwise: the first one that is not the shelf.
205
- function firstOpenStatus(board) {
206
- const open = [...board.statuses]
207
- .sort((left, right) => left.order - right.order)
208
- .find((status) => status.id !== ArchivedBoardStatusId);
209
- if (!open) {
210
- throw new IntelError(409, "board_status_unknown", "This board has no status a task could be added to");
211
- }
212
- return open.id;
213
- }
214
- function childrenOf(tasks) {
215
- const children = new Map();
216
- for (const task of tasks) {
217
- const siblings = children.get(task.parentId);
218
- if (siblings)
219
- siblings.push(task);
220
- else
221
- children.set(task.parentId, [task]);
222
- }
223
- return children;
224
- }
225
- /**
226
- * The task and everything under it, breadth first.
227
- *
228
- * ⚠️ The walk is bounded by the number of tasks rather than by the depth, so a board whose
229
- * `parentId` chain somehow closed on itself — one written before the cycle check, or restored
230
- * from a bundle — is counted once and does not spin here.
231
- */
232
- function withDescendants(tasks, rootId) {
233
- const children = childrenOf(tasks);
234
- const removed = new Set([rootId]);
235
- const queue = [rootId];
236
- for (let index = 0; index < queue.length; index += 1) {
237
- const current = queue[index];
238
- if (current === undefined)
239
- continue;
240
- for (const child of children.get(current) ?? []) {
241
- if (removed.has(child.id))
242
- continue;
243
- removed.add(child.id);
244
- queue.push(child.id);
245
- }
246
- }
247
- return removed;
248
- }
249
- function depthOf(tasks, taskId) {
250
- let depth = 0;
251
- let current = taskId;
252
- const seen = new Set();
253
- while (current !== null) {
254
- if (seen.has(current))
255
- break;
256
- seen.add(current);
257
- depth += 1;
258
- current = tasks.find((task) => task.id === current)?.parentId ?? null;
259
- }
260
- return depth;
261
- }
262
- function heightOf(tasks, rootId) {
263
- const children = childrenOf(tasks);
264
- let height = 1;
265
- let level = [rootId];
266
- const seen = new Set(level);
267
- while (level.length > 0) {
268
- const next = [];
269
- for (const id of level) {
270
- for (const child of children.get(id) ?? []) {
271
- if (seen.has(child.id))
272
- continue;
273
- seen.add(child.id);
274
- next.push(child.id);
275
- }
276
- }
277
- if (next.length > 0)
278
- height += 1;
279
- level = next;
280
- }
281
- return height;
282
- }
283
- /**
284
- * Whether this task may hang under that parent (#285).
285
- *
286
- * ⚠️ Two refusals, not one, because they are two different mistakes. Hanging a task under its own
287
- * descendant is a cycle — a subtree that has left the board without being deleted, invisible in
288
- * every view. Hanging a legal subtree too deep is a bound: `BoardMaxTaskDepth` counts the whole
289
- * chain, so moving an epic under another epic is measured with its own children, not as if it
290
- * were a single card.
291
- */
292
- function requireParent(tasks, taskId, parentId) {
293
- if (parentId === null)
294
- return;
295
- if (parentId === taskId) {
296
- throw new IntelError(409, "board_parent_cycle", "A task cannot be its own parent");
297
- }
298
- if (!tasks.some((task) => task.id === parentId)) {
299
- throw new IntelError(422, "board_parent_unknown", "The parent task is not on this board");
300
- }
301
- if (withDescendants(tasks, taskId).has(parentId)) {
302
- throw new IntelError(409, "board_parent_cycle", "A task cannot be moved under one of its own subtasks");
303
- }
304
- const depth = depthOf(tasks, parentId) + heightOf(tasks, taskId);
305
- if (depth > BoardMaxTaskDepth) {
306
- throw new IntelError(422, "board_depth_exceeded", `Tasks nest at most ${BoardMaxTaskDepth} deep, and this would be ${depth}`);
307
- }
308
- }
309
- /**
310
- * Whether these dependencies exist here and describe no circle (#285).
311
- *
312
- * ⚠️ A dependency on a task this board does not hold is refused BECAUSE it might be a task in
313
- * another board. Accepting it would hang this node on a file that can be edited, moved or deleted
314
- * without anything here noticing — across boards the link is `references`, which names the board
315
- * NODE and is a link the graph can see. "Unknown" and "in another board" are deliberately the same
316
- * refusal: telling them apart would mean confirming that a task id exists somewhere the caller may
317
- * have no access to.
318
- *
319
- * ⚠️ Own check, run over the whole graph and separate from the `parentId` walk. The two describe
320
- * different things — where a card sits and what it waits for — and a subtask that waits for its
321
- * own epic is legal in one and would be a cycle in the other.
322
- */
323
- function requireDependencies(tasks, taskId) {
324
- const byId = new Map(tasks.map((task) => [task.id, task]));
325
- for (const task of tasks) {
326
- for (const dependency of task.dependsOn) {
327
- if (!byId.has(dependency)) {
328
- throw new IntelError(422, "board_dependency_unknown", `A task can only depend on a task of the same board, and “${dependency}” is not one`);
329
- }
330
- }
331
- }
332
- // Depth-first from the changed task alone: everything else was acyclic before this write, so a
333
- // circle that exists now has to run through the one task that changed.
334
- const stack = [taskId];
335
- const path = new Set();
336
- const done = new Set();
337
- const visit = (id) => {
338
- if (done.has(id))
339
- return;
340
- if (path.has(id)) {
341
- throw new IntelError(409, "board_dependency_cycle", "These dependencies would wait for each other in a circle");
342
- }
343
- path.add(id);
344
- for (const dependency of byId.get(id)?.dependsOn ?? [])
345
- visit(dependency);
346
- path.delete(id);
347
- done.add(id);
348
- };
349
- for (const id of stack)
350
- visit(id);
351
- }
352
- /**
353
- * The order key for a task's new place.
354
- *
355
- * ⚠️ Neighbours are read from the board's ONE key space rather than from the target column. A key
356
- * minted between two cards is between them globally, and a filtered view of a sorted list is still
357
- * sorted, so a column shows exactly what the caller asked for while no second key space has to be
358
- * kept in step with the first.
359
- *
360
- * With no neighbour named, the task goes last in the column it is landing in — which is what
361
- * "add a task" means without further instruction, and what a drop onto an empty column means.
362
- */
363
- function placementKey(board, placement, movingTaskId) {
364
- const others = sorted(board.tasks.filter((task) => task.id !== movingTaskId));
365
- const indexOf = (taskId) => {
366
- const index = others.findIndex((task) => task.id === taskId);
367
- if (index < 0) {
368
- throw new IntelError(422, "board_neighbour_unknown", "The neighbour task is not on this board");
369
- }
370
- return index;
371
- };
372
- let afterIndex = placement.afterTaskId === null ? null : indexOf(placement.afterTaskId);
373
- const beforeIndex = placement.beforeTaskId === null ? null : indexOf(placement.beforeTaskId);
374
- if (afterIndex !== null && beforeIndex !== null && afterIndex >= beforeIndex) {
375
- throw new IntelError(400, "board_neighbour_order", "The task named as the predecessor does not come before the one named as the successor");
376
- }
377
- if (afterIndex === null && beforeIndex === null) {
378
- const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
379
- const last = column.at(-1);
380
- if (!last)
381
- return generateKeyBetween(others.at(-1)?.order ?? null, null);
382
- afterIndex = others.indexOf(last);
383
- }
384
- const lower = afterIndex === null ? null : (others[afterIndex]?.order ?? null);
385
- const upper = beforeIndex !== null
386
- ? (others[beforeIndex]?.order ?? null)
387
- : afterIndex !== null
388
- ? (others[afterIndex + 1]?.order ?? null)
389
- : null;
390
- return generateKeyBetween(lower, upper);
391
- }
392
- function replaced(board, task) {
393
- return {
394
- statuses: board.statuses,
395
- tasks: board.tasks.map((candidate) => (candidate.id === task.id ? task : candidate)),
396
- };
397
- }
398
- return {
399
- defaultBoard() {
400
- return { statuses: [...BoardDefaultStatuses], tasks: [] };
401
- },
402
- /**
403
- * The status list, written whole.
404
- *
405
- * ⚠️ `archived` is asserted here as well as at the boundary, and that is not the usual
406
- * duplication. It is the rule the whole "sweep it away instead of deleting it" promise rests
407
- * on, and a board that lost its shelf cannot be repaired by the caller who lost it — every task
408
- * sitting there would point at a status that no longer exists.
409
- *
410
- * ⚠️ A status that tasks still sit in cannot be dropped. The alternative — moving those tasks
411
- * somewhere — is a decision about somebody's work, and this call is not the place to make it
412
- * silently.
413
- */
414
- configure(board, statuses) {
415
- if (!statuses.some((status) => status.id === ArchivedBoardStatusId)) {
416
- throw new IntelError(422, "board_status_archived_required", `The “${ArchivedBoardStatusId}” status cannot be removed`);
417
- }
418
- const kept = new Set(statuses.map((status) => status.id));
419
- const orphaned = board.tasks.find((task) => !kept.has(task.status));
420
- if (orphaned) {
421
- throw new IntelError(409, "board_status_in_use", `Tasks still sit in “${orphaned.status}”, so it cannot be removed`);
422
- }
423
- const next = statuses.map((status, index) => ({
424
- id: status.id,
425
- label: status.label,
426
- order: index,
427
- // ⚠️ The shelf is always terminal and the boundary refuses an explicit `false` for it, so
428
- // this is not a silent correction of something a caller asked for — it is the same fact
429
- // stated where the document is built. Everything else defaults to "not finished": a new
430
- // column is work, and a column that ends work is a thing somebody says on purpose (#311).
431
- terminal: status.id === ArchivedBoardStatusId ? true : (status.terminal ?? false),
432
- }));
433
- return { statuses: next, tasks: board.tasks };
434
- },
435
- addTask(board, input) {
436
- if (board.tasks.length >= 5_000) {
437
- throw new IntelError(409, "board_task_limit", "This board holds as many tasks as it can");
438
- }
439
- const status = input.status ?? firstOpenStatus(board);
440
- requireStatus(board, status);
441
- const task = {
442
- id: deps.id(),
443
- title: input.title,
444
- status,
445
- assignee: input.assignee,
446
- labels: input.labels,
447
- startDate: input.startDate,
448
- dueDate: input.dueDate,
449
- parentId: input.parentId,
450
- dependsOn: input.dependsOn,
451
- order: placementKey(board, {
452
- status,
453
- parentId: input.parentId,
454
- afterTaskId: input.afterTaskId,
455
- beforeTaskId: input.beforeTaskId,
456
- }, null),
457
- description: input.description,
458
- references: input.references,
459
- };
460
- const tasks = [...board.tasks, task];
461
- requireParent(tasks, task.id, task.parentId);
462
- requireDependencies(tasks, task.id);
463
- return { board: { statuses: board.statuses, tasks }, task };
464
- },
465
- updateTask(board, input) {
466
- const current = taskOf(board, input.taskId);
467
- const task = {
468
- ...current,
469
- ...(input.title !== undefined && { title: input.title }),
470
- ...(input.assignee !== undefined && { assignee: input.assignee }),
471
- ...(input.labels !== undefined && { labels: input.labels }),
472
- ...(input.startDate !== undefined && { startDate: input.startDate }),
473
- ...(input.dueDate !== undefined && { dueDate: input.dueDate }),
474
- ...(input.dependsOn !== undefined && { dependsOn: input.dependsOn }),
475
- ...(input.description !== undefined && { description: input.description }),
476
- ...(input.references !== undefined && { references: input.references }),
477
- };
478
- const next = replaced(board, task);
479
- requireDependencies(next.tasks, task.id);
480
- return { board: next, task };
481
- },
482
- moveTask(board, input) {
483
- const current = taskOf(board, input.taskId);
484
- const status = input.status ?? current.status;
485
- requireStatus(board, status);
486
- const parentId = input.parentId === undefined ? current.parentId : input.parentId;
487
- // The parent is checked against the board as it stands, before the order key is minted: a
488
- // refused move must not have spent one, and `placementKey` is the only step that cannot be
489
- // undone by simply not returning the document.
490
- requireParent(board.tasks, current.id, parentId);
491
- const task = {
492
- ...current,
493
- status,
494
- parentId,
495
- order: placementKey(board, {
496
- status,
497
- parentId,
498
- afterTaskId: input.afterTaskId,
499
- beforeTaskId: input.beforeTaskId,
500
- }, current.id),
501
- };
502
- return { board: replaced(board, task), task };
503
- },
504
- /**
505
- * The task and everything under it (#285).
506
- *
507
- * ⚠️ Deleting cascades rather than orphaning: a subtask whose epic is gone has no place on any
508
- * board, and leaving it behind under a `parentId` that names nothing is a card no view can file.
509
- * The count comes back so a surface can say what it is about to do.
510
- *
511
- * ⚠️ The dependencies of what survives are cleaned up in the same act. A `dependsOn` pointing at
512
- * a deleted task is an edge to nothing — it would draw a line into empty space in the graph view
513
- * and make a task wait forever for something that cannot happen.
514
- */
515
- deleteTask(board, taskId) {
516
- taskOf(board, taskId);
517
- const removed = withDescendants(board.tasks, taskId);
518
- const tasks = board.tasks
519
- .filter((task) => !removed.has(task.id))
520
- .map((task) => {
521
- const dependsOn = task.dependsOn.filter((dependency) => !removed.has(dependency));
522
- return dependsOn.length === task.dependsOn.length ? task : { ...task, dependsOn };
523
- });
524
- return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
525
- },
526
- sorted,
527
- };
528
- }
@@ -1,31 +0,0 @@
1
- import type { AddBoardTaskInput, BoardDocument, BoardStatusInput, BoardTask, MoveBoardTaskInput, UpdateBoardTaskInput } from "@anchrd/intel-contract";
2
- export interface BoardDeps {
3
- id(): string;
4
- }
5
- export interface BoardPlacement {
6
- status: string;
7
- parentId: string | null;
8
- afterTaskId: string | null;
9
- beforeTaskId: string | null;
10
- }
11
- export interface BoardOperations {
12
- defaultBoard(): BoardDocument;
13
- configure(board: BoardDocument, statuses: BoardStatusInput[]): BoardDocument;
14
- addTask(board: BoardDocument, input: AddBoardTaskInput): {
15
- board: BoardDocument;
16
- task: BoardTask;
17
- };
18
- updateTask(board: BoardDocument, input: UpdateBoardTaskInput): {
19
- board: BoardDocument;
20
- task: BoardTask;
21
- };
22
- moveTask(board: BoardDocument, input: MoveBoardTaskInput): {
23
- board: BoardDocument;
24
- task: BoardTask;
25
- };
26
- deleteTask(board: BoardDocument, taskId: string): {
27
- board: BoardDocument;
28
- deleted: number;
29
- };
30
- sorted(tasks: readonly BoardTask[]): BoardTask[];
31
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,76 +0,0 @@
1
- -- #139: a fifth kind in the shared tree — `agent`. It is a node like the other four: same
2
- -- `parent_id`, same folder grants, same immutable `node_versions` rows, same R2 body (ADR-0005 §1).
3
- -- Its body happens to be a JSON definition 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
- -- SQLite cannot alter a CHECK constraint, so the table is rebuilt — the same rebuild 0005 performed
7
- -- for `table`, written the same long way round for the same two reasons. Both detours below are
8
- -- copied from a file that earned them against a real database, not from caution.
9
- --
10
- -- ⚠️ First detour: the new table is created under the final name rather than built beside the old
11
- -- one and renamed over it. `DROP TABLE` on a parent runs an implicit `DELETE FROM` first, so the
12
- -- moment the old `nodes` goes, every row of `node_versions`, `tree_grants` and `flows` pointing at a
13
- -- node is a foreign-key violation. `defer_foreign_keys` postpones the complaint to COMMIT but does
14
- -- not withdraw it, and `ALTER TABLE ... RENAME` does not settle it either: a rename puts the name
15
- -- back, not the rows. Only inserting the nodes again, under the name the children have referenced
16
- -- all along, does. This is also why the self-reference below reads `REFERENCES nodes(id)` — the
17
- -- final name — which is lesson 2 of the three recorded in `0009_no_context_policy.sql`.
18
- --
19
- -- ⚠️ Second detour: `node_links` is the only child of `nodes` declared ON DELETE CASCADE, so that
20
- -- same implicit delete does not merely flag its rows, it removes them — the migration would commit
21
- -- with every relationship between two documents quietly gone. The rows are carried out of the way
22
- -- first and put back afterwards. That is a rescue, not a decision about the data: nothing is
23
- -- dropped, rewritten or reinterpreted here.
24
- --
25
- -- ⚠️ On an empty database neither detour is visible, because nothing points at anything. That is
26
- -- exactly how 0005's first version passed a green suite and then failed against the first database
27
- -- with content in it, and why the proof for this file is a row count of every referencing table
28
- -- before and after rather than a migration that merely ran.
29
- --
30
- -- `context_policy` is carried over unchanged and still NOT NULL. It is dead for every consumer
31
- -- (#76) and only D1's refusal to drop a column a CHECK names keeps it here; removing it is #86 and
32
- -- deliberately not smuggled into this rebuild.
33
- PRAGMA defer_foreign_keys = TRUE;
34
-
35
- -- Plain holding tables on purpose: no keys, no CHECKs, no foreign keys, and the column set taken
36
- -- from whatever the live table has. Anything enforced here would only be enforced a second time on
37
- -- the way back in, and a holding table that can reject a row is a holding table that can lose one.
38
- CREATE TABLE nodes_carry AS SELECT * FROM nodes;
39
- CREATE TABLE node_links_carry AS SELECT * FROM node_links;
40
-
41
- DROP TABLE nodes;
42
-
43
- CREATE TABLE nodes (
44
- id TEXT PRIMARY KEY NOT NULL,
45
- parent_id TEXT REFERENCES nodes(id),
46
- kind TEXT NOT NULL CHECK (kind IN ('folder', 'document', 'attachment', 'table', 'agent')),
47
- title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 240),
48
- description TEXT CHECK (description IS NULL OR length(description) <= 2000),
49
- context_policy TEXT NOT NULL CHECK (context_policy IN ('pinned', 'relevant', 'explicit')),
50
- owner_id TEXT NOT NULL,
51
- current_version_id TEXT,
52
- created_at TEXT NOT NULL,
53
- updated_at TEXT NOT NULL,
54
- archived_at TEXT
55
- );
56
-
57
- INSERT INTO nodes (
58
- id, parent_id, kind, title, description, context_policy, owner_id,
59
- current_version_id, created_at, updated_at, archived_at
60
- )
61
- SELECT
62
- id, parent_id, kind, title, description, context_policy, owner_id,
63
- current_version_id, created_at, updated_at, archived_at
64
- FROM nodes_carry;
65
-
66
- -- `OR IGNORE` because whether the cascade above actually fired is SQLite's business, not this
67
- -- migration's: if it did, this puts the rows back; if it did not, each one is already present under
68
- -- the same primary key and this is a no-op. Either way `node_links` ends up holding exactly what it
69
- -- held before, which is the only outcome this statement is permitted to have.
70
- INSERT OR IGNORE INTO node_links SELECT * FROM node_links_carry;
71
-
72
- DROP TABLE nodes_carry;
73
- DROP TABLE node_links_carry;
74
-
75
- CREATE INDEX nodes_parent_idx ON nodes(parent_id, archived_at, title);
76
- CREATE INDEX nodes_owner_idx ON nodes(owner_id, archived_at);
@@ -1,25 +0,0 @@
1
- -- #182: which Gate Application an agent node runs as. One row per agent that has a principal, and
2
- -- nothing else — the row is a NAME, never a credential.
3
- --
4
- -- ⚠️ The Application KEY has no column here and must never get one (D27). Gate hands a key out once
5
- -- in plain text and keeps only its hash, so there is nothing to store even in principle; the key
6
- -- lives in the runtime's `AGENT_APPLICATION_KEYS` secret and reaches it through the one response
7
- -- that created the agent. A column for it would turn every backup of this database into a set of
8
- -- machine credentials.
9
- --
10
- -- ⚠️ A table rather than a column on `nodes`, for two reasons. The column would be NULL for every
11
- -- folder, document, attachment and table there will ever be, which is a shape that says nothing
12
- -- about four of the five kinds; and adding it would mean rebuilding `nodes` — SQLite cannot alter
13
- -- a table a CHECK constrains — with the two detours `0013_agents_in_the_tree.sql` records. This
14
- -- file writes one new table and touches nothing that exists.
15
- --
16
- -- ⚠️ Deliberately NOT `ON DELETE CASCADE`, unlike `node_links`. Migration 0013 explains what that
17
- -- one cascade cost the rebuild it had to survive: an implicit `DELETE FROM` on the parent removed
18
- -- its rows outright rather than merely flagging them, and they had to be carried out of the way and
19
- -- put back. A second cascading child would hand the next rebuild the same trap twice. Nodes are
20
- -- archived, never deleted, so nothing is being kept alive by leaving the cascade off.
21
- CREATE TABLE agent_applications (
22
- node_id TEXT PRIMARY KEY NOT NULL REFERENCES nodes(id),
23
- application_id TEXT NOT NULL,
24
- created_at TEXT NOT NULL
25
- );