@anchrd/intel-api 0.14.0 → 0.16.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 +0 -68
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
- package/dist/adapters/db/db-flows.js +3 -2
- package/dist/adapters/db/db-grants.d.ts +1 -1
- package/dist/adapters/db/db-grants.js +1 -1
- package/dist/adapters/db/db.js +16 -113
- package/dist/adapters/openid/openid.js +1 -1
- package/dist/adapters/remote-tools/remote-tools.js +1 -1
- package/dist/bundle/bundle.js +31 -141
- package/dist/bundle/bundle.types.d.ts +1 -1
- package/dist/cli/cli.js +18 -11
- package/dist/flows/flows.d.ts +1 -1
- package/dist/flows/flows.js +1 -1
- package/dist/flows/flows.types.d.ts +4 -1
- package/dist/http/http.js +43 -238
- package/dist/http/http.types.d.ts +0 -8
- package/dist/indexing/indexing.js +18 -89
- package/dist/intel/intel.js +4 -9
- package/dist/intel/intel.types.d.ts +0 -6
- package/dist/mcp/mcp.js +84 -361
- package/dist/mcp/mcp.types.d.ts +2 -7
- package/dist/nodes/document-links/document-links.d.ts +6 -8
- package/dist/nodes/document-links/document-links.js +8 -31
- package/dist/nodes/nodes.js +24 -890
- package/dist/nodes/nodes.types.d.ts +14 -173
- package/dist/tools/tool-servers/tool-servers.d.ts +1 -1
- package/dist/tools/tool-servers/tool-servers.js +1 -1
- package/dist/tools/tools.js +37 -148
- package/dist/tools/tools.types.d.ts +1 -22
- package/migrations/0018_no_context_policy_at_last.sql +13 -6
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/package.json +2 -2
- package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
- package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -306
- package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
- package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
- package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
- package/dist/adapters/gate-applications/gate-applications.js +0 -88
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
- package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
- package/dist/agent-costs/agent-costs.d.ts +0 -16
- package/dist/agent-costs/agent-costs.js +0 -105
- package/dist/agent-costs/agent-costs.types.d.ts +0 -30
- package/dist/agent-costs/agent-costs.types.js +0 -1
- package/dist/agent-runtime/agent-runtime.d.ts +0 -16
- package/dist/agent-runtime/agent-runtime.js +0 -150
- package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
- package/dist/agent-runtime/agent-runtime.types.js +0 -1
- package/dist/model-catalog/model-catalog.d.ts +0 -2
- package/dist/model-catalog/model-catalog.js +0 -99
- package/dist/model-catalog/model-catalog.types.d.ts +0 -15
- package/dist/model-catalog/model-catalog.types.js +0 -1
- package/dist/nodes/board/board.d.ts +0 -61
- package/dist/nodes/board/board.js +0 -826
- package/dist/nodes/board/board.types.d.ts +0 -38
- package/dist/nodes/board/board.types.js +0 -1
- package/migrations/0013_agents_in_the_tree.sql +0 -76
- package/migrations/0014_agent_applications.sql +0 -25
- package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
- package/migrations/0016_boards_in_the_tree.sql +0 -80
|
@@ -1,826 +0,0 @@
|
|
|
1
|
-
import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, MaxBoardTaskOrderLength, } 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. 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.
|
|
153
|
-
*
|
|
154
|
-
* So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
|
|
155
|
-
* document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
|
|
156
|
-
* and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
|
|
157
|
-
* #285.
|
|
158
|
-
*/
|
|
159
|
-
export function repeatedBoardId(document) {
|
|
160
|
-
for (const list of ["statuses", "tasks"]) {
|
|
161
|
-
const seen = new Set();
|
|
162
|
-
for (const entry of document[list]) {
|
|
163
|
-
if (seen.has(entry.id))
|
|
164
|
-
return { list, id: entry.id };
|
|
165
|
-
seen.add(entry.id);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
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
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Every board operation, applied to the document rather than to the file (#285).
|
|
251
|
-
*
|
|
252
|
-
* ⚠️ This module is where a board's rules live, and it is deliberately pure: it takes a document
|
|
253
|
-
* and gives back the next one. The version, R2 and the retry against a racing write are the
|
|
254
|
-
* service's business (`nodes.ts`), and the surfaces have no rules of their own — HTTP, MCP and the
|
|
255
|
-
* UI all arrive here. A second implementation of "may this task depend on that one" is the thing
|
|
256
|
-
* this shape exists to prevent.
|
|
257
|
-
*
|
|
258
|
-
* ⚠️ What it does NOT check is anything that needs the tree: whether a `references` id names a node
|
|
259
|
-
* the writer may see, and whether an `agent` assignee is really an agent. Those are reads against
|
|
260
|
-
* D1 and the ACL, so they happen in the service before the document is touched at all.
|
|
261
|
-
*/
|
|
262
|
-
export function createBoard(deps) {
|
|
263
|
-
function byOrder(left, right) {
|
|
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.
|
|
272
|
-
if (left.order === right.order)
|
|
273
|
-
return left.id < right.id ? -1 : 1;
|
|
274
|
-
return left.order < right.order ? -1 : 1;
|
|
275
|
-
}
|
|
276
|
-
function sorted(tasks) {
|
|
277
|
-
return [...tasks].sort(byOrder);
|
|
278
|
-
}
|
|
279
|
-
function taskOf(board, taskId) {
|
|
280
|
-
const task = board.tasks.find((candidate) => candidate.id === taskId);
|
|
281
|
-
if (!task)
|
|
282
|
-
throw new IntelError(404, "board_task_not_found", "Task was not found");
|
|
283
|
-
return task;
|
|
284
|
-
}
|
|
285
|
-
function requireStatus(board, statusId) {
|
|
286
|
-
if (!board.statuses.some((status) => status.id === statusId)) {
|
|
287
|
-
throw new IntelError(422, "board_status_unknown", `This board has no status “${statusId}”`);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
// The column a task lands in when nobody says otherwise: the first one that is not the shelf.
|
|
291
|
-
function firstOpenStatus(board) {
|
|
292
|
-
const open = [...board.statuses]
|
|
293
|
-
.sort((left, right) => left.order - right.order)
|
|
294
|
-
.find((status) => status.id !== ArchivedBoardStatusId);
|
|
295
|
-
if (!open) {
|
|
296
|
-
throw new IntelError(409, "board_status_unknown", "This board has no status a task could be added to");
|
|
297
|
-
}
|
|
298
|
-
return open.id;
|
|
299
|
-
}
|
|
300
|
-
function childrenOf(tasks) {
|
|
301
|
-
const children = new Map();
|
|
302
|
-
for (const task of tasks) {
|
|
303
|
-
const siblings = children.get(task.parentId);
|
|
304
|
-
if (siblings)
|
|
305
|
-
siblings.push(task);
|
|
306
|
-
else
|
|
307
|
-
children.set(task.parentId, [task]);
|
|
308
|
-
}
|
|
309
|
-
return children;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* The task and everything under it, breadth first.
|
|
313
|
-
*
|
|
314
|
-
* ⚠️ The walk is bounded by the number of tasks rather than by the depth, so a board whose
|
|
315
|
-
* `parentId` chain somehow closed on itself — one written before the cycle check, or restored
|
|
316
|
-
* from a bundle — is counted once and does not spin here.
|
|
317
|
-
*/
|
|
318
|
-
function withDescendants(tasks, rootId) {
|
|
319
|
-
const children = childrenOf(tasks);
|
|
320
|
-
const removed = new Set([rootId]);
|
|
321
|
-
const queue = [rootId];
|
|
322
|
-
for (let index = 0; index < queue.length; index += 1) {
|
|
323
|
-
const current = queue[index];
|
|
324
|
-
if (current === undefined)
|
|
325
|
-
continue;
|
|
326
|
-
for (const child of children.get(current) ?? []) {
|
|
327
|
-
if (removed.has(child.id))
|
|
328
|
-
continue;
|
|
329
|
-
removed.add(child.id);
|
|
330
|
-
queue.push(child.id);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return removed;
|
|
334
|
-
}
|
|
335
|
-
function depthOf(tasks, taskId) {
|
|
336
|
-
let depth = 0;
|
|
337
|
-
let current = taskId;
|
|
338
|
-
const seen = new Set();
|
|
339
|
-
while (current !== null) {
|
|
340
|
-
if (seen.has(current))
|
|
341
|
-
break;
|
|
342
|
-
seen.add(current);
|
|
343
|
-
depth += 1;
|
|
344
|
-
current = tasks.find((task) => task.id === current)?.parentId ?? null;
|
|
345
|
-
}
|
|
346
|
-
return depth;
|
|
347
|
-
}
|
|
348
|
-
function heightOf(tasks, rootId) {
|
|
349
|
-
const children = childrenOf(tasks);
|
|
350
|
-
let height = 1;
|
|
351
|
-
let level = [rootId];
|
|
352
|
-
const seen = new Set(level);
|
|
353
|
-
while (level.length > 0) {
|
|
354
|
-
const next = [];
|
|
355
|
-
for (const id of level) {
|
|
356
|
-
for (const child of children.get(id) ?? []) {
|
|
357
|
-
if (seen.has(child.id))
|
|
358
|
-
continue;
|
|
359
|
-
seen.add(child.id);
|
|
360
|
-
next.push(child.id);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
if (next.length > 0)
|
|
364
|
-
height += 1;
|
|
365
|
-
level = next;
|
|
366
|
-
}
|
|
367
|
-
return height;
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* Whether this task may hang under that parent (#285).
|
|
371
|
-
*
|
|
372
|
-
* ⚠️ Two refusals, not one, because they are two different mistakes. Hanging a task under its own
|
|
373
|
-
* descendant is a cycle — a subtree that has left the board without being deleted, invisible in
|
|
374
|
-
* every view. Hanging a legal subtree too deep is a bound: `BoardMaxTaskDepth` counts the whole
|
|
375
|
-
* chain, so moving an epic under another epic is measured with its own children, not as if it
|
|
376
|
-
* were a single card.
|
|
377
|
-
*/
|
|
378
|
-
function requireParent(tasks, taskId, parentId) {
|
|
379
|
-
if (parentId === null)
|
|
380
|
-
return;
|
|
381
|
-
if (parentId === taskId) {
|
|
382
|
-
throw new IntelError(409, "board_parent_cycle", "A task cannot be its own parent");
|
|
383
|
-
}
|
|
384
|
-
if (!tasks.some((task) => task.id === parentId)) {
|
|
385
|
-
throw new IntelError(422, "board_parent_unknown", "The parent task is not on this board");
|
|
386
|
-
}
|
|
387
|
-
if (withDescendants(tasks, taskId).has(parentId)) {
|
|
388
|
-
throw new IntelError(409, "board_parent_cycle", "A task cannot be moved under one of its own subtasks");
|
|
389
|
-
}
|
|
390
|
-
const depth = depthOf(tasks, parentId) + heightOf(tasks, taskId);
|
|
391
|
-
if (depth > BoardMaxTaskDepth) {
|
|
392
|
-
throw new IntelError(422, "board_depth_exceeded", `Tasks nest at most ${BoardMaxTaskDepth} deep, and this would be ${depth}`);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* Whether these dependencies exist here and describe no circle (#285).
|
|
397
|
-
*
|
|
398
|
-
* ⚠️ A dependency on a task this board does not hold is refused BECAUSE it might be a task in
|
|
399
|
-
* another board. Accepting it would hang this node on a file that can be edited, moved or deleted
|
|
400
|
-
* without anything here noticing — across boards the link is `references`, which names the board
|
|
401
|
-
* NODE and is a link the graph can see. "Unknown" and "in another board" are deliberately the same
|
|
402
|
-
* refusal: telling them apart would mean confirming that a task id exists somewhere the caller may
|
|
403
|
-
* have no access to.
|
|
404
|
-
*
|
|
405
|
-
* ⚠️ Own check, run over the whole graph and separate from the `parentId` walk. The two describe
|
|
406
|
-
* different things — where a card sits and what it waits for — and a subtask that waits for its
|
|
407
|
-
* own epic is legal in one and would be a cycle in the other.
|
|
408
|
-
*/
|
|
409
|
-
function requireDependencies(tasks, taskId) {
|
|
410
|
-
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
411
|
-
for (const task of tasks) {
|
|
412
|
-
for (const dependency of task.dependsOn) {
|
|
413
|
-
if (!byId.has(dependency)) {
|
|
414
|
-
throw new IntelError(422, "board_dependency_unknown", `A task can only depend on a task of the same board, and “${dependency}” is not one`);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// Depth-first from the changed task alone: everything else was acyclic before this write, so a
|
|
419
|
-
// circle that exists now has to run through the one task that changed.
|
|
420
|
-
const stack = [taskId];
|
|
421
|
-
const path = new Set();
|
|
422
|
-
const done = new Set();
|
|
423
|
-
const visit = (id) => {
|
|
424
|
-
if (done.has(id))
|
|
425
|
-
return;
|
|
426
|
-
if (path.has(id)) {
|
|
427
|
-
throw new IntelError(409, "board_dependency_cycle", "These dependencies would wait for each other in a circle");
|
|
428
|
-
}
|
|
429
|
-
path.add(id);
|
|
430
|
-
for (const dependency of byId.get(id)?.dependsOn ?? [])
|
|
431
|
-
visit(dependency);
|
|
432
|
-
path.delete(id);
|
|
433
|
-
done.add(id);
|
|
434
|
-
};
|
|
435
|
-
for (const id of stack)
|
|
436
|
-
visit(id);
|
|
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
|
-
}
|
|
523
|
-
/**
|
|
524
|
-
* The order key for a task's new place.
|
|
525
|
-
*
|
|
526
|
-
* ⚠️ Neighbours are read from the board's ONE key space rather than from the target column. A key
|
|
527
|
-
* minted between two cards is between them globally, and a filtered view of a sorted list is still
|
|
528
|
-
* sorted, so a column shows exactly what the caller asked for while no second key space has to be
|
|
529
|
-
* kept in step with the first.
|
|
530
|
-
*
|
|
531
|
-
* With no neighbour named, the task goes last in the column it is landing in — which is what
|
|
532
|
-
* "add a task" means without further instruction, and what a drop onto an empty column means.
|
|
533
|
-
*/
|
|
534
|
-
function placementKey(board, placement, movingTaskId) {
|
|
535
|
-
const others = sorted(board.tasks.filter((task) => task.id !== movingTaskId));
|
|
536
|
-
const indexOf = (taskId) => {
|
|
537
|
-
const index = others.findIndex((task) => task.id === taskId);
|
|
538
|
-
if (index < 0) {
|
|
539
|
-
throw new IntelError(422, "board_neighbour_unknown", "The neighbour task is not on this board");
|
|
540
|
-
}
|
|
541
|
-
return index;
|
|
542
|
-
};
|
|
543
|
-
let afterIndex = placement.afterTaskId === null ? null : indexOf(placement.afterTaskId);
|
|
544
|
-
const beforeIndex = placement.beforeTaskId === null ? null : indexOf(placement.beforeTaskId);
|
|
545
|
-
if (afterIndex !== null && beforeIndex !== null && afterIndex >= beforeIndex) {
|
|
546
|
-
throw new IntelError(400, "board_neighbour_order", "The task named as the predecessor does not come before the one named as the successor");
|
|
547
|
-
}
|
|
548
|
-
if (afterIndex === null && beforeIndex === null) {
|
|
549
|
-
const column = others.filter((task) => task.status === placement.status && task.parentId === placement.parentId);
|
|
550
|
-
const last = column.at(-1);
|
|
551
|
-
if (!last)
|
|
552
|
-
return mintedKey(others.at(-1)?.order ?? null, null);
|
|
553
|
-
afterIndex = others.indexOf(last);
|
|
554
|
-
}
|
|
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;
|
|
563
|
-
const upper = beforeIndex !== null
|
|
564
|
-
? (others[beforeIndex]?.order ?? null)
|
|
565
|
-
: afterIndex !== null
|
|
566
|
-
? nextOrderAbove(others, afterIndex)
|
|
567
|
-
: null;
|
|
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;
|
|
601
|
-
}
|
|
602
|
-
function replaced(board, task) {
|
|
603
|
-
return {
|
|
604
|
-
statuses: board.statuses,
|
|
605
|
-
tasks: board.tasks.map((candidate) => (candidate.id === task.id ? task : candidate)),
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
return {
|
|
609
|
-
defaultBoard() {
|
|
610
|
-
return { statuses: [...BoardDefaultStatuses], tasks: [] };
|
|
611
|
-
},
|
|
612
|
-
/**
|
|
613
|
-
* The status list, written whole.
|
|
614
|
-
*
|
|
615
|
-
* ⚠️ `archived` is asserted here as well as at the boundary, and that is not the usual
|
|
616
|
-
* duplication. It is the rule the whole "sweep it away instead of deleting it" promise rests
|
|
617
|
-
* on, and a board that lost its shelf cannot be repaired by the caller who lost it — every task
|
|
618
|
-
* sitting there would point at a status that no longer exists.
|
|
619
|
-
*
|
|
620
|
-
* ⚠️ A status that tasks still sit in cannot be dropped. The alternative — moving those tasks
|
|
621
|
-
* somewhere — is a decision about somebody's work, and this call is not the place to make it
|
|
622
|
-
* silently.
|
|
623
|
-
*/
|
|
624
|
-
configure(board, statuses) {
|
|
625
|
-
if (!statuses.some((status) => status.id === ArchivedBoardStatusId)) {
|
|
626
|
-
throw new IntelError(422, "board_status_archived_required", `The “${ArchivedBoardStatusId}” status cannot be removed`);
|
|
627
|
-
}
|
|
628
|
-
const kept = new Set(statuses.map((status) => status.id));
|
|
629
|
-
const orphaned = board.tasks.find((task) => !kept.has(task.status));
|
|
630
|
-
if (orphaned) {
|
|
631
|
-
throw new IntelError(409, "board_status_in_use", `Tasks still sit in “${orphaned.status}”, so it cannot be removed`);
|
|
632
|
-
}
|
|
633
|
-
const next = statuses.map((status, index) => ({
|
|
634
|
-
id: status.id,
|
|
635
|
-
label: status.label,
|
|
636
|
-
order: index,
|
|
637
|
-
// ⚠️ The shelf is always terminal and the boundary refuses an explicit `false` for it, so
|
|
638
|
-
// this is not a silent correction of something a caller asked for — it is the same fact
|
|
639
|
-
// stated where the document is built. Everything else defaults to "not finished": a new
|
|
640
|
-
// column is work, and a column that ends work is a thing somebody says on purpose (#311).
|
|
641
|
-
terminal: status.id === ArchivedBoardStatusId ? true : (status.terminal ?? false),
|
|
642
|
-
}));
|
|
643
|
-
return { statuses: next, tasks: board.tasks };
|
|
644
|
-
},
|
|
645
|
-
addTask(board, input) {
|
|
646
|
-
if (board.tasks.length >= 5_000) {
|
|
647
|
-
throw new IntelError(409, "board_task_limit", "This board holds as many tasks as it can");
|
|
648
|
-
}
|
|
649
|
-
const status = input.status ?? firstOpenStatus(board);
|
|
650
|
-
requireStatus(board, status);
|
|
651
|
-
const task = {
|
|
652
|
-
id: deps.id(),
|
|
653
|
-
title: input.title,
|
|
654
|
-
status,
|
|
655
|
-
assignee: input.assignee,
|
|
656
|
-
labels: input.labels,
|
|
657
|
-
startDate: input.startDate,
|
|
658
|
-
dueDate: input.dueDate,
|
|
659
|
-
parentId: input.parentId,
|
|
660
|
-
dependsOn: input.dependsOn,
|
|
661
|
-
order: placementKey(board, {
|
|
662
|
-
status,
|
|
663
|
-
parentId: input.parentId,
|
|
664
|
-
afterTaskId: input.afterTaskId,
|
|
665
|
-
beforeTaskId: input.beforeTaskId,
|
|
666
|
-
}, null),
|
|
667
|
-
description: input.description,
|
|
668
|
-
references: input.references,
|
|
669
|
-
};
|
|
670
|
-
const tasks = [...board.tasks, task];
|
|
671
|
-
requireParent(tasks, task.id, task.parentId);
|
|
672
|
-
requireDependencies(tasks, task.id);
|
|
673
|
-
return { board: { statuses: board.statuses, tasks }, task };
|
|
674
|
-
},
|
|
675
|
-
updateTask(board, input) {
|
|
676
|
-
const current = taskOf(board, input.taskId);
|
|
677
|
-
const task = {
|
|
678
|
-
...current,
|
|
679
|
-
...(input.title !== undefined && { title: input.title }),
|
|
680
|
-
...(input.assignee !== undefined && { assignee: input.assignee }),
|
|
681
|
-
...(input.labels !== undefined && { labels: input.labels }),
|
|
682
|
-
...(input.startDate !== undefined && { startDate: input.startDate }),
|
|
683
|
-
...(input.dueDate !== undefined && { dueDate: input.dueDate }),
|
|
684
|
-
...(input.dependsOn !== undefined && { dependsOn: input.dependsOn }),
|
|
685
|
-
...(input.description !== undefined && { description: input.description }),
|
|
686
|
-
...(input.references !== undefined && { references: input.references }),
|
|
687
|
-
};
|
|
688
|
-
const next = replaced(board, task);
|
|
689
|
-
requireDependencies(next.tasks, task.id);
|
|
690
|
-
return { board: next, task };
|
|
691
|
-
},
|
|
692
|
-
moveTask(board, input) {
|
|
693
|
-
const current = taskOf(board, input.taskId);
|
|
694
|
-
const status = input.status ?? current.status;
|
|
695
|
-
requireStatus(board, status);
|
|
696
|
-
const parentId = input.parentId === undefined ? current.parentId : input.parentId;
|
|
697
|
-
// The parent is checked against the board as it stands, before the order key is minted: a
|
|
698
|
-
// refused move must not have spent one, and `placementKey` is the only step that cannot be
|
|
699
|
-
// undone by simply not returning the document.
|
|
700
|
-
requireParent(board.tasks, current.id, parentId);
|
|
701
|
-
const task = {
|
|
702
|
-
...current,
|
|
703
|
-
status,
|
|
704
|
-
parentId,
|
|
705
|
-
order: placementKey(board, {
|
|
706
|
-
status,
|
|
707
|
-
parentId,
|
|
708
|
-
afterTaskId: input.afterTaskId,
|
|
709
|
-
beforeTaskId: input.beforeTaskId,
|
|
710
|
-
}, current.id),
|
|
711
|
-
};
|
|
712
|
-
return { board: replaced(board, task), task };
|
|
713
|
-
},
|
|
714
|
-
/**
|
|
715
|
-
* The task and everything under it (#285).
|
|
716
|
-
*
|
|
717
|
-
* ⚠️ Deleting cascades rather than orphaning: a subtask whose epic is gone has no place on any
|
|
718
|
-
* board, and leaving it behind under a `parentId` that names nothing is a card no view can file.
|
|
719
|
-
* The count comes back so a surface can say what it is about to do.
|
|
720
|
-
*
|
|
721
|
-
* ⚠️ The dependencies of what survives are cleaned up in the same act. A `dependsOn` pointing at
|
|
722
|
-
* a deleted task is an edge to nothing — it would draw a line into empty space in the graph view
|
|
723
|
-
* and make a task wait forever for something that cannot happen.
|
|
724
|
-
*/
|
|
725
|
-
deleteTask(board, taskId) {
|
|
726
|
-
taskOf(board, taskId);
|
|
727
|
-
const removed = withDescendants(board.tasks, taskId);
|
|
728
|
-
const tasks = board.tasks
|
|
729
|
-
.filter((task) => !removed.has(task.id))
|
|
730
|
-
.map((task) => {
|
|
731
|
-
const dependsOn = task.dependsOn.filter((dependency) => !removed.has(dependency));
|
|
732
|
-
return dependsOn.length === task.dependsOn.length ? task : { ...task, dependsOn };
|
|
733
|
-
});
|
|
734
|
-
return { board: { statuses: board.statuses, tasks }, deleted: removed.size };
|
|
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
|
-
},
|
|
824
|
-
sorted,
|
|
825
|
-
};
|
|
826
|
-
}
|