@anchrd/intel-api 0.28.0 → 0.29.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 +4 -0
- package/dist/adapters/db/db-boards.js +51 -13
- package/dist/adapters/db/db.js +16 -0
- package/dist/boards/boards.d.ts +15 -1
- package/dist/boards/boards.js +131 -9
- package/dist/boards/boards.types.d.ts +26 -1
- package/dist/mcp/mcp.js +2 -2
- package/dist/nodes/nodes.js +136 -13
- package/dist/nodes/nodes.types.d.ts +29 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/package.json +2 -2
|
@@ -123,6 +123,10 @@ export default {
|
|
|
123
123
|
// ⚠️ The REPOSITORY, not the board service — that is what keeps this from being a cycle. The
|
|
124
124
|
// board service needs the node service to create a task; the node service needs only the two
|
|
125
125
|
// reads below to file one.
|
|
126
|
+
// ⚠️ `"write"` for the same reason as below: this answers "may this actor file a card here",
|
|
127
|
+
// not "may they look at it". Reading it with `"read"` would let somebody nest a card under a
|
|
128
|
+
// task on a board they may only look at.
|
|
129
|
+
boardOfTask: async (who, taskId) => (await boardRepository.taskRow(who, taskId, "write"))?.boardId ?? null,
|
|
126
130
|
attachToBoard: async (who, taskId, boardId, occurredAt) => {
|
|
127
131
|
// ⚠️ `"write"` — the actor is filing a task on this board, and the board's own creation
|
|
128
132
|
// path is a write too. `"read"` here would put back exactly the hole #649 found.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BoardColumn } from "@anchrd/intel-contract/board";
|
|
1
|
+
import { ARCHIVE_COLUMN_ID, BoardColumn } from "@anchrd/intel-contract/board";
|
|
2
2
|
import { subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
3
3
|
function parseList(raw, of) {
|
|
4
4
|
// The column is `NOT NULL DEFAULT '[]'`, but it is JSON written by this repository and read back
|
|
@@ -14,16 +14,28 @@ function parseList(raw, of) {
|
|
|
14
14
|
return [];
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
function mapTask(row) {
|
|
17
|
+
function mapTask(row, boardId) {
|
|
18
18
|
return {
|
|
19
19
|
id: row.id,
|
|
20
20
|
title: row.title,
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* ⚠️ **An archived card reports the ARCHIVE column, not the one it came from** — that is what
|
|
23
|
+
* makes the column real instead of a label. `board_tasks.status` keeps naming the working
|
|
24
|
+
* column underneath, untouched, and that is what a card pulled back out returns to.
|
|
25
|
+
*
|
|
26
|
+
* A card archived over `node_archive`, without anybody dragging it, therefore appears here too.
|
|
27
|
+
* That is the test which tells this design apart from a second stored status (D68).
|
|
28
|
+
*/
|
|
29
|
+
status: row.archived_at === null ? row.status : ARCHIVE_COLUMN_ID,
|
|
22
30
|
assigneeId: row.assignee_id,
|
|
23
31
|
labels: parseList(row.labels_json, "labels").filter((entry) => typeof entry === "string"),
|
|
24
32
|
startDate: row.start_date,
|
|
25
33
|
dueDate: row.due_date,
|
|
26
34
|
dependsOn: row.depends_on,
|
|
35
|
+
// ⚠️ The board itself is not a parent TASK. A card filed directly on the board has
|
|
36
|
+
// `parent_id = boardId`, and handing that back as `parentTaskId` would make every top-level
|
|
37
|
+
// card claim to be a subtask of the board — a tree one level too deep, in every view.
|
|
38
|
+
parentTaskId: row.parent_id === boardId ? null : row.parent_id,
|
|
27
39
|
position: row.position,
|
|
28
40
|
archivedAt: row.archived_at,
|
|
29
41
|
};
|
|
@@ -53,7 +65,8 @@ export function createBoardRepository(deps) {
|
|
|
53
65
|
async read(actor, input) {
|
|
54
66
|
const board = await deps.db
|
|
55
67
|
.prepare(`${subtreeCte}
|
|
56
|
-
SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json
|
|
68
|
+
SELECT n.id, n.title, COALESCE(b.statuses_json, '[]') AS statuses_json,
|
|
69
|
+
COALESCE(b.archive_visible, 1) AS archive_visible
|
|
57
70
|
FROM nodes n
|
|
58
71
|
JOIN allowed ON allowed.id = n.id
|
|
59
72
|
LEFT JOIN boards b ON b.node_id = n.id
|
|
@@ -64,7 +77,14 @@ export function createBoardRepository(deps) {
|
|
|
64
77
|
return null;
|
|
65
78
|
const conditions = ["t.board_id = ?"];
|
|
66
79
|
const bindings = [input.boardId];
|
|
67
|
-
if (input.status
|
|
80
|
+
if (input.status === ARCHIVE_COLUMN_ID) {
|
|
81
|
+
// ⚠️ The archive column is not a stored status — `board_tasks.status` keeps naming the
|
|
82
|
+
// working column underneath (D68). Matched against `t.status` the filter would answer
|
|
83
|
+
// "nothing here" on a board full of visibly archived cards, and the column would look
|
|
84
|
+
// broken rather than empty.
|
|
85
|
+
conditions.push("n.archived_at IS NOT NULL");
|
|
86
|
+
}
|
|
87
|
+
else if (input.status !== undefined) {
|
|
68
88
|
conditions.push("t.status = ?");
|
|
69
89
|
bindings.push(input.status);
|
|
70
90
|
}
|
|
@@ -83,11 +103,18 @@ export function createBoardRepository(deps) {
|
|
|
83
103
|
conditions.push("t.depends_on = ?");
|
|
84
104
|
bindings.push(input.dependsOn);
|
|
85
105
|
}
|
|
86
|
-
|
|
106
|
+
/**
|
|
107
|
+
* ⚠️ **The archive column IS the archived cards** (D68, #674). While it is drawn they belong
|
|
108
|
+
* in the answer, or the column would always be empty and nobody could pull anything back out
|
|
109
|
+
* of it. `includeArchived` stays for the other direction: a caller — an agent, a report —
|
|
110
|
+
* that wants them even on a board where the column is hidden.
|
|
111
|
+
*/
|
|
112
|
+
const archiveVisible = board.archive_visible !== 0;
|
|
113
|
+
if (!input.includeArchived && !archiveVisible)
|
|
87
114
|
conditions.push("n.archived_at IS NULL");
|
|
88
115
|
const tasks = await deps.db
|
|
89
116
|
.prepare(`${subtreeCte}
|
|
90
|
-
SELECT n.id, n.title, t.status, t.assignee_id, t.labels_json,
|
|
117
|
+
SELECT n.id, n.title, n.parent_id, t.status, t.assignee_id, t.labels_json,
|
|
91
118
|
t.start_date, t.due_date, t.depends_on, t.position, n.archived_at
|
|
92
119
|
FROM board_tasks t
|
|
93
120
|
JOIN nodes n ON n.id = t.node_id
|
|
@@ -96,6 +123,10 @@ export function createBoardRepository(deps) {
|
|
|
96
123
|
ORDER BY t.status, t.position, n.id`)
|
|
97
124
|
.bind(...walkBindings(actor, "read"), ...bindings)
|
|
98
125
|
.all();
|
|
126
|
+
// ⚠️ The CONFIGURED columns only. The archive column is appended by the service, after the
|
|
127
|
+
// fallback for an unconfigured board has been applied — appended here it would make every
|
|
128
|
+
// board look configured, and a board with no columns of its own would answer with the
|
|
129
|
+
// archive and nothing else.
|
|
99
130
|
const columns = parseList(board.statuses_json, "columns")
|
|
100
131
|
.map((entry) => BoardColumn.safeParse(entry))
|
|
101
132
|
.filter((parsed) => parsed.success)
|
|
@@ -103,8 +134,9 @@ export function createBoardRepository(deps) {
|
|
|
103
134
|
return {
|
|
104
135
|
boardId: board.id,
|
|
105
136
|
title: board.title,
|
|
137
|
+
archiveVisible,
|
|
106
138
|
columns,
|
|
107
|
-
tasks: (tasks.results ?? []).map(mapTask),
|
|
139
|
+
tasks: (tasks.results ?? []).map((row) => mapTask(row, board.id)),
|
|
108
140
|
};
|
|
109
141
|
},
|
|
110
142
|
async columnsOf(actor, boardId, verb) {
|
|
@@ -124,13 +156,17 @@ export function createBoardRepository(deps) {
|
|
|
124
156
|
.filter((parsed) => parsed.success)
|
|
125
157
|
.map((parsed) => parsed.data.id);
|
|
126
158
|
},
|
|
127
|
-
async setColumns(boardId, columnsJson, occurredAt) {
|
|
159
|
+
async setColumns(boardId, columnsJson, occurredAt, archiveVisible) {
|
|
160
|
+
// ⚠️ `COALESCE(?, archive_visible)` rather than a second statement: an absent flag means
|
|
161
|
+
// "leave it", and writing a default here would un-hide an archive somebody put away every
|
|
162
|
+
// time the columns are reordered.
|
|
128
163
|
await deps.db
|
|
129
|
-
.prepare(`INSERT INTO boards (node_id, statuses_json, created_at, updated_at)
|
|
130
|
-
VALUES (?, ?, ?, ?)
|
|
164
|
+
.prepare(`INSERT INTO boards (node_id, statuses_json, archive_visible, created_at, updated_at)
|
|
165
|
+
VALUES (?, ?, COALESCE(?, 1), ?, ?)
|
|
131
166
|
ON CONFLICT(node_id) DO UPDATE SET statuses_json = excluded.statuses_json,
|
|
167
|
+
archive_visible = COALESCE(?, boards.archive_visible),
|
|
132
168
|
updated_at = excluded.updated_at`)
|
|
133
|
-
.bind(boardId, columnsJson, occurredAt, occurredAt)
|
|
169
|
+
.bind(boardId, columnsJson, archiveVisible === undefined ? null : Number(archiveVisible), occurredAt, occurredAt, archiveVisible === undefined ? null : Number(archiveVisible))
|
|
134
170
|
.run();
|
|
135
171
|
},
|
|
136
172
|
/**
|
|
@@ -154,7 +190,7 @@ export function createBoardRepository(deps) {
|
|
|
154
190
|
async taskRow(actor, taskId, verb) {
|
|
155
191
|
const row = await deps.db
|
|
156
192
|
.prepare(`${subtreeCte}
|
|
157
|
-
SELECT t.node_id, t.board_id, t.status, t.position
|
|
193
|
+
SELECT t.node_id, t.board_id, t.status, t.position, n.archived_at, n.updated_at
|
|
158
194
|
FROM board_tasks t
|
|
159
195
|
JOIN nodes n ON n.id = t.node_id
|
|
160
196
|
JOIN allowed ON allowed.id = n.id
|
|
@@ -168,6 +204,8 @@ export function createBoardRepository(deps) {
|
|
|
168
204
|
boardId: row.board_id,
|
|
169
205
|
status: row.status,
|
|
170
206
|
position: row.position,
|
|
207
|
+
archivedAt: row.archived_at,
|
|
208
|
+
updatedAt: row.updated_at,
|
|
171
209
|
};
|
|
172
210
|
},
|
|
173
211
|
/**
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -176,6 +176,22 @@ export function createNodeRepository(deps) {
|
|
|
176
176
|
withChildren: rows.filter((row) => row.has_children === 1).map((row) => row.id),
|
|
177
177
|
};
|
|
178
178
|
},
|
|
179
|
+
/**
|
|
180
|
+
* ⚠️ Deliberately NOT `visibleChildren`: that builder cuts tasks out of every level, so it
|
|
181
|
+
* answers `0` for a task with any number of subtasks. This one asks the question the guard
|
|
182
|
+
* actually has — "is anything still hanging under this node".
|
|
183
|
+
*/
|
|
184
|
+
async countLiveChildren(nodeId) {
|
|
185
|
+
// ⚠️ No `allowed` join, deliberately: a grant can sit on a single node, so a child may be
|
|
186
|
+
// invisible to whoever archives its parent. Filtered by visibility this answers `0`, the
|
|
187
|
+
// refusal does not fire, and the child is later purged without being named. A count leaks a
|
|
188
|
+
// number and nothing else; the alternative leaks the child itself, permanently.
|
|
189
|
+
const result = await deps.db
|
|
190
|
+
.prepare("SELECT COUNT(*) AS total FROM nodes WHERE parent_id = ? AND archived_at IS NULL")
|
|
191
|
+
.bind(nodeId)
|
|
192
|
+
.first();
|
|
193
|
+
return result?.total ?? 0;
|
|
194
|
+
},
|
|
179
195
|
async listVisibleBounded(actor, input) {
|
|
180
196
|
const result = await deps.db
|
|
181
197
|
.prepare(visibleChildren(true))
|
package/dist/boards/boards.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { BoardDeps, BoardService } from "./boards.types.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
4
|
+
* nothing asks whoever made it to define columns before filing the first card.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
|
|
7
|
+
* shared data: two people on one board must read the same names, and the settings dialog edits one
|
|
8
|
+
* list. So the application writes them at creation, in the language of whoever created it (#675),
|
|
9
|
+
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
10
|
+
* configured — one made over MCP, or one whose seeding did not land.
|
|
11
|
+
*/
|
|
2
12
|
export declare const DEFAULT_COLUMNS: readonly [{
|
|
13
|
+
readonly id: "backlog";
|
|
14
|
+
readonly title: "Backlog";
|
|
15
|
+
readonly terminal: false;
|
|
16
|
+
}, {
|
|
3
17
|
readonly id: "todo";
|
|
4
18
|
readonly title: "Offen";
|
|
5
19
|
readonly terminal: false;
|
|
@@ -12,5 +26,5 @@ export declare const DEFAULT_COLUMNS: readonly [{
|
|
|
12
26
|
readonly title: "Fertig";
|
|
13
27
|
readonly terminal: true;
|
|
14
28
|
}];
|
|
15
|
-
export declare const FIRST_DEFAULT_COLUMN: "
|
|
29
|
+
export declare const FIRST_DEFAULT_COLUMN: "backlog";
|
|
16
30
|
export declare function createBoards(deps: BoardDeps): BoardService;
|
package/dist/boards/boards.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
1
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
5
|
+
* nothing asks whoever made it to define columns before filing the first card.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **This is the FALLBACK, not what a board made through the application gets.** Columns are
|
|
8
|
+
* shared data: two people on one board must read the same names, and the settings dialog edits one
|
|
9
|
+
* list. So the application writes them at creation, in the language of whoever created it (#675),
|
|
10
|
+
* and from then on they are data anybody can rename. This list only answers for a board nobody
|
|
11
|
+
* configured — one made over MCP, or one whose seeding did not land.
|
|
12
|
+
*/
|
|
4
13
|
export const DEFAULT_COLUMNS = [
|
|
14
|
+
{ id: "backlog", title: "Backlog", terminal: false },
|
|
5
15
|
{ id: "todo", title: "Offen", terminal: false },
|
|
6
16
|
{ id: "doing", title: "Läuft", terminal: false },
|
|
7
17
|
{ id: "done", title: "Fertig", terminal: true },
|
|
@@ -35,7 +45,35 @@ export function createBoards(deps) {
|
|
|
35
45
|
// A board that was never configured answers with the default columns rather than none. An empty
|
|
36
46
|
// column list would render as a board with no columns at all — a screen with nowhere to put a
|
|
37
47
|
// card, which looks broken rather than new.
|
|
38
|
-
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ **A stored column may not claim the archive id, and the migration is not the only guard.**
|
|
50
|
+
* `0024` renames the ones that existed before the id was reserved, and `update` refuses new
|
|
51
|
+
* ones — but a row written straight into D1, or a migration that did not reach an installation,
|
|
52
|
+
* would still put two columns with one id in front of a reader. Dropping it here means the
|
|
53
|
+
* derived one always wins, which is the answer that cannot produce two.
|
|
54
|
+
*/
|
|
55
|
+
const configured = (view.columns.length > 0 ? view.columns : [...DEFAULT_COLUMNS]).filter((column) => column.id !== ARCHIVE_COLUMN_ID);
|
|
56
|
+
/**
|
|
57
|
+
* ⚠️ **The archive column is appended here, after the fallback, and it is never stored.** A
|
|
58
|
+
* card is in it because `nodes.archived_at` is set; `board_tasks.status` keeps naming the
|
|
59
|
+
* working column underneath, which is what a card pulled back out returns to (D68).
|
|
60
|
+
*
|
|
61
|
+
* Always last, and not part of the list `board_update` writes: the archive is the same place on
|
|
62
|
+
* every board. A shelf that could be dragged between the working columns would be a shelf
|
|
63
|
+
* pretending to be a stage.
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ **The title here is a fallback, not the label a reader sees.** Configured columns are
|
|
66
|
+
* shared data and are therefore written once in the creator's language (#675); this one is
|
|
67
|
+
* derived and identical on every board, so the browser says it in the reader's own words
|
|
68
|
+
* (`useBoard`). What travels is English, for everything without a catalog — MCP, an export, a
|
|
69
|
+
* report.
|
|
70
|
+
*/
|
|
71
|
+
return {
|
|
72
|
+
...view,
|
|
73
|
+
columns: view.archiveVisible
|
|
74
|
+
? [...configured, { id: ARCHIVE_COLUMN_ID, title: "Archive", terminal: true }]
|
|
75
|
+
: configured,
|
|
76
|
+
};
|
|
39
77
|
};
|
|
40
78
|
const requireColumn = (columns, status) => {
|
|
41
79
|
if (columns.includes(status))
|
|
@@ -56,7 +94,28 @@ export function createBoards(deps) {
|
|
|
56
94
|
if (duplicate !== undefined) {
|
|
57
95
|
throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
|
|
58
96
|
}
|
|
59
|
-
|
|
97
|
+
/**
|
|
98
|
+
* ⚠️ **The archive id cannot be claimed by a configured column.** It is derived — a card is in
|
|
99
|
+
* it because `archived_at` is set — so a stored column of the same id would give a board two
|
|
100
|
+
* columns with one id: every card in them lands in whichever the view draws first, and one of
|
|
101
|
+
* the two can never receive a card at all.
|
|
102
|
+
*/
|
|
103
|
+
/**
|
|
104
|
+
* ⚠️ **Duplicate NAMES are refused here, not only in the dialog.** An id nobody sees may
|
|
105
|
+
* repeat without confusing anybody; a name is what a person reads, and two columns called the
|
|
106
|
+
* same thing cannot be told apart on the board, in the filter, or in the settings. Checked in
|
|
107
|
+
* the service because an agent calling `board_update` reaches no dialog — root `CLAUDE.md`:
|
|
108
|
+
* validation does not vary by surface.
|
|
109
|
+
*/
|
|
110
|
+
const titles = input.columns.map((column) => column.title.trim());
|
|
111
|
+
const duplicateTitle = titles.find((title, index) => titles.indexOf(title) !== index);
|
|
112
|
+
if (duplicateTitle !== undefined) {
|
|
113
|
+
throw new IntelError(400, "duplicate_column_name", `Two columns are called \`${duplicateTitle}\`. A name is what tells them apart.`);
|
|
114
|
+
}
|
|
115
|
+
if (ids.includes(ARCHIVE_COLUMN_ID)) {
|
|
116
|
+
throw new IntelError(400, "reserved_column", `\`${ARCHIVE_COLUMN_ID}\` is the archive column, which every board has. Show or hide it instead of configuring it.`);
|
|
117
|
+
}
|
|
118
|
+
await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString(), input.archiveVisible);
|
|
60
119
|
return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
|
|
61
120
|
},
|
|
62
121
|
async createTask(actor, input) {
|
|
@@ -67,11 +126,40 @@ export function createBoards(deps) {
|
|
|
67
126
|
const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
|
|
68
127
|
if (input.status !== undefined)
|
|
69
128
|
requireColumn(columns, status);
|
|
129
|
+
/**
|
|
130
|
+
* ⚠️ **The parent's board must BE the board that was asked for**, checked here rather than
|
|
131
|
+
* left to the node path. `nodes.create` resolves the destination board from the parent, and
|
|
132
|
+
* on creation there is no previous board to compare it with — so a call naming board B with a
|
|
133
|
+
* parent on board A files the card on **A**, answers with the view of **B**, and the caller
|
|
134
|
+
* gets neither an error nor their card. The position would have been counted against B too.
|
|
135
|
+
*/
|
|
136
|
+
if (input.parentTaskId !== null) {
|
|
137
|
+
// ⚠️ `"write"` for the same reason `attachToBoard` uses it — filing a card here is a write
|
|
138
|
+
// to that board. It is defence in depth rather than the only guard: `nodes.create` checks
|
|
139
|
+
// `write` on the parent node itself and answers 403 first, which is why a behaviour test
|
|
140
|
+
// cannot tell `"read"` from `"write"` here. Named so the next reader does not go looking
|
|
141
|
+
// for the test that proves it.
|
|
142
|
+
const parentRow = await deps.boards.taskRow(actor, input.parentTaskId, "write");
|
|
143
|
+
/**
|
|
144
|
+
* ⚠️ **Only the wrong BOARD is refused here.** `taskRow` answers `null` for three different
|
|
145
|
+
* things — the node does not exist, it is not a task, or this actor may not write it — and
|
|
146
|
+
* each of those already has a precise answer further in: `404 node_not_found` from
|
|
147
|
+
* `requireVisible`, `task_belongs_to_a_board` from `refuseWrongParent`, `403` from the
|
|
148
|
+
* write check. Catching them here would answer "that parent card is not on this board" for
|
|
149
|
+
* a card that does not exist, which claims it does.
|
|
150
|
+
*/
|
|
151
|
+
if (parentRow !== null && parentRow.boardId !== input.boardId) {
|
|
152
|
+
throw new IntelError(409, "task_cannot_change_board", "That parent card is not on this board. A subtask belongs to the same board as the card it hangs under.");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
70
155
|
// ⚠️ Through the node service, not a second insert. The task gets the same authorization
|
|
71
156
|
// check, the same audit row and the same version chain every other node gets — and that audit
|
|
72
157
|
// row is what `anchrd/signals` reads (#620).
|
|
73
158
|
const node = await deps.nodes.create(actor, {
|
|
74
|
-
|
|
159
|
+
// ⚠️ A subtask's parent is the TASK, not the board — the hierarchy is the node tree (#669).
|
|
160
|
+
// The node service resolves which board that lands on and files the row there, so nothing
|
|
161
|
+
// here has to know: naming the board as parent would flatten every subtask on creation.
|
|
162
|
+
parentId: input.parentTaskId ?? input.boardId,
|
|
75
163
|
kind: "task",
|
|
76
164
|
title: input.title,
|
|
77
165
|
description: null,
|
|
@@ -117,7 +205,26 @@ export function createBoards(deps) {
|
|
|
117
205
|
if (row === null) {
|
|
118
206
|
throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
|
|
119
207
|
}
|
|
120
|
-
|
|
208
|
+
/**
|
|
209
|
+
* ⚠️ **Dropping a card on the archive column archives it; pulling it out restores it** (D68).
|
|
210
|
+
* Neither writes `board_tasks.status`: the working column underneath is what a restored card
|
|
211
|
+
* returns to, and overwriting it with `archived` would be the second stored truth this
|
|
212
|
+
* design exists against.
|
|
213
|
+
*
|
|
214
|
+
* `nodes.archive` is the same door `node_archive` uses — one path, one audit row, one set of
|
|
215
|
+
* refusals (a card with live subtasks is still refused, #669).
|
|
216
|
+
*/
|
|
217
|
+
const intoArchive = input.status === ARCHIVE_COLUMN_ID;
|
|
218
|
+
const outOfArchive = input.status !== undefined && !intoArchive && row.archivedAt !== null;
|
|
219
|
+
if (intoArchive || outOfArchive) {
|
|
220
|
+
await deps.nodes.archive(actor, {
|
|
221
|
+
nodeId: input.taskId,
|
|
222
|
+
baseUpdatedAt: row.updatedAt,
|
|
223
|
+
archived: intoArchive,
|
|
224
|
+
idempotencyKey: `${input.idempotencyKey}:archive`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (input.status !== undefined && !intoArchive) {
|
|
121
228
|
requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
|
|
122
229
|
}
|
|
123
230
|
// Only what arrived. An absent field means "leave it"; `null` where the column is nullable
|
|
@@ -129,9 +236,16 @@ export function createBoards(deps) {
|
|
|
129
236
|
// one would make two rows disagree about what a card is called.
|
|
130
237
|
throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
|
|
131
238
|
}
|
|
132
|
-
|
|
239
|
+
// ⚠️ Not for a drop into the archive: the working column stays as it is, so the card knows
|
|
240
|
+
// where to go back to.
|
|
241
|
+
if (input.status !== undefined && !intoArchive)
|
|
133
242
|
fields.status = input.status;
|
|
134
|
-
|
|
243
|
+
// ⚠️ Not for a drop into the archive either. The kanban sends a position with EVERY drop,
|
|
244
|
+
// computed from the neighbours inside the column it was dropped on — and inside the archive
|
|
245
|
+
// those are cards from all over the board. Taken, it would overwrite the card's place in the
|
|
246
|
+
// working column with a number that means nothing there. The status is guarded one line up;
|
|
247
|
+
// without this line the guard only covered half the write.
|
|
248
|
+
if (input.position !== undefined && !intoArchive)
|
|
135
249
|
fields.position = input.position;
|
|
136
250
|
if (input.assigneeId !== undefined)
|
|
137
251
|
fields.assignee_id = input.assigneeId;
|
|
@@ -152,9 +266,17 @@ export function createBoards(deps) {
|
|
|
152
266
|
* on every attempt while every call reported success. That is the idempotency the tool
|
|
153
267
|
* promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
|
|
154
268
|
*/
|
|
269
|
+
/**
|
|
270
|
+
* ⚠️ **Never for a drop into the archive**, and the reason is the same one that keeps
|
|
271
|
+
* `fields.status` unset there: `board_tasks.status` never holds `archived`, so
|
|
272
|
+
* `nextPosition(board, "archived")` counts zero rows and answers `1` — every time. The card
|
|
273
|
+
* keeps its working column but silently loses its place IN it, landing first among cards it
|
|
274
|
+
* used to sit behind. Nothing reports that, and the reader only sees it after restoring.
|
|
275
|
+
*/
|
|
155
276
|
if (input.status !== undefined &&
|
|
156
277
|
input.position === undefined &&
|
|
157
|
-
row.status !== input.status
|
|
278
|
+
row.status !== input.status &&
|
|
279
|
+
!intoArchive) {
|
|
158
280
|
fields.position = await deps.boards.nextPosition(row.boardId, input.status);
|
|
159
281
|
}
|
|
160
282
|
if (Object.keys(fields).length > 0) {
|
|
@@ -7,6 +7,15 @@ export interface BoardTaskRow {
|
|
|
7
7
|
boardId: string;
|
|
8
8
|
status: string;
|
|
9
9
|
position: number;
|
|
10
|
+
/**
|
|
11
|
+
* From the NODE, not from `board_tasks` (D68, #674).
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Whether a card is archived is `nodes.archived_at` and nothing else. The row carries it so
|
|
14
|
+
* that a move can tell "into the archive" from "out of it" without a second read — and so that
|
|
15
|
+
* nobody is tempted to store the answer here, which would be the two truths D68 exists against.
|
|
16
|
+
*/
|
|
17
|
+
archivedAt: string | null;
|
|
18
|
+
updatedAt: string;
|
|
10
19
|
}
|
|
11
20
|
export interface BoardTaskWrite {
|
|
12
21
|
nodeId: string;
|
|
@@ -23,7 +32,12 @@ export interface BoardTaskWrite {
|
|
|
23
32
|
export interface BoardRepository {
|
|
24
33
|
read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
|
|
25
34
|
columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
|
|
26
|
-
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ `archiveVisible` is `undefined` for "leave it as it is" — the dialog that writes the columns
|
|
37
|
+
* and the switch that hides the archive are the same call, and a caller who only reorders columns
|
|
38
|
+
* must not silently un-hide an archive somebody put away.
|
|
39
|
+
*/
|
|
40
|
+
setColumns(boardId: string, columnsJson: string, occurredAt: string, archiveVisible?: boolean): Promise<void>;
|
|
27
41
|
attach(write: BoardTaskWrite): Promise<void>;
|
|
28
42
|
taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
|
|
29
43
|
updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
|
|
@@ -37,6 +51,17 @@ export interface BoardNodePort {
|
|
|
37
51
|
description: null;
|
|
38
52
|
idempotencyKey: string;
|
|
39
53
|
}): Promise<Node>;
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ The same door `node_archive` uses. Dropping a card on the archive column must not become a
|
|
56
|
+
* second way to archive: one path means one audit row, one set of refusals — a card with live
|
|
57
|
+
* subtasks stays refused (#669) — and one answer to "what does archived mean".
|
|
58
|
+
*/
|
|
59
|
+
archive(actor: Actor, input: {
|
|
60
|
+
nodeId: string;
|
|
61
|
+
baseUpdatedAt: string;
|
|
62
|
+
archived: boolean;
|
|
63
|
+
idempotencyKey: string;
|
|
64
|
+
}): Promise<Node>;
|
|
40
65
|
}
|
|
41
66
|
export interface BoardDeps {
|
|
42
67
|
boards: BoardRepository;
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -182,7 +182,7 @@ export async function handleMcp(request, deps) {
|
|
|
182
182
|
}, async (input) => text(await deps.boards.get(actor, input)));
|
|
183
183
|
server.registerTool("board_task_create", {
|
|
184
184
|
title: "Add a card to a board",
|
|
185
|
-
description: "File a new card on a board. Omit the status to put it in the first column. The card is a node like any other
|
|
185
|
+
description: "File a new card on a board. Omit the status to put it in the first column. Name a parent task to make it a subtask — it lands on the same board, and moving an existing card under another one is `node_update` with a new parent. The card is a node like any other: its own address, its own permissions, its own history.",
|
|
186
186
|
inputSchema: BoardTaskCreateInput,
|
|
187
187
|
annotations: {
|
|
188
188
|
title: "Add a card to a board",
|
|
@@ -194,7 +194,7 @@ export async function handleMcp(request, deps) {
|
|
|
194
194
|
}, async (input) => text(await deps.boards.createTask(actor, input)));
|
|
195
195
|
server.registerTool("board_task_update", {
|
|
196
196
|
title: "Change a card",
|
|
197
|
-
description: "Change a card's column, order, assignee, labels or dates. Moving a card is this call with a new status — send a position to place it, or leave it out to put it at the end of the column. An absent field is left alone; null clears one. Rename a card with node_update.",
|
|
197
|
+
description: "Change a card's column, order, assignee, labels or dates. Moving a card is this call with a new status — send a position to place it, or leave it out to put it at the end of the column. An absent field is left alone; null clears one. Rename a card with node_update. To move a card UNDER another card, use `node_update` with the other card as its parent — this tool changes what a card is, not where it hangs.",
|
|
198
198
|
inputSchema: BoardTaskUpdateInput,
|
|
199
199
|
annotations: {
|
|
200
200
|
title: "Change a card",
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -83,20 +83,84 @@ function decodeBase64(value) {
|
|
|
83
83
|
* ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
|
|
84
84
|
* dragging a card into a folder has to learn what went wrong, not that something did.
|
|
85
85
|
*/
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* ⚠️ **Nothing may be filed under an ARCHIVED node**, and this is not tidiness — it is what keeps
|
|
88
|
+
* the purge honest.
|
|
89
|
+
*
|
|
90
|
+
* A parent may be archived while it is empty; `task_has_subtasks` only refuses one that still has
|
|
91
|
+
* live children. Without this rule it could then GAIN a live child afterwards, and `purge` — which
|
|
92
|
+
* asks only whether the node itself is archived — walks the whole subtree and takes that child
|
|
93
|
+
* with it. It would disappear for good without ever being named in the confirmation.
|
|
94
|
+
*
|
|
95
|
+
* Found in review of #669, against my own sentence claiming purge needed no rule of its own. That
|
|
96
|
+
* sentence was true only as long as an archived node could never gain a live child, and nothing
|
|
97
|
+
* made it true.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The board a node would be filed on: the board itself, or the board of the task it hangs under
|
|
101
|
+
* (#669).
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ **One function, because there are three callers and they must not drift.** The create path,
|
|
104
|
+
* the move and the idempotent replay all ask the same question, and the replay was written when a
|
|
105
|
+
* task's parent was always its board — it passed `parentId` straight through as the board id. With
|
|
106
|
+
* nesting that files a subtask on a "board" that is another card, and `board_tasks.board_id` has no
|
|
107
|
+
* foreign key saying otherwise. Found in review of #669: the main path had been corrected and this
|
|
108
|
+
* one had not, which is the shape of `anchrd/intel#457` — two places answering one question, only
|
|
109
|
+
* one of them told about the change.
|
|
110
|
+
*/
|
|
111
|
+
async function boardFor(deps, actor, parent) {
|
|
112
|
+
if (parent === null)
|
|
113
|
+
return null;
|
|
114
|
+
if (parent.kind === "board")
|
|
115
|
+
return parent.id;
|
|
116
|
+
// ⚠️ Only a TASK can have a board, and asking for anything else costs a D1 round trip on every
|
|
117
|
+
// write in the whole tree — a document filed in a folder would pay for a board feature. The
|
|
118
|
+
// question is not "do we know the board" but "can this parent have one at all".
|
|
119
|
+
if (parent.kind !== "task")
|
|
120
|
+
return null;
|
|
121
|
+
return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
|
|
122
|
+
}
|
|
123
|
+
function refuseArchivedParent(childKind, parent) {
|
|
124
|
+
// ⚠️ Tasks only, and the narrowness is deliberate. The same hole exists for a folder — archive an
|
|
125
|
+
// empty one, file a document in it, purge — but it is OLDER than this ticket and fixing it here
|
|
126
|
+
// would change the behaviour of the whole tree out of a board ticket, with no test outside
|
|
127
|
+
// `boards.int.ts` covering it. It is #679, which also asks what such data already exists.
|
|
128
|
+
//
|
|
129
|
+
// ⚠️ A reference without its number is not a reference: the review of #677 searched for this
|
|
130
|
+
// ticket and could not find it, because this comment did not name it.
|
|
131
|
+
if (childKind !== "task")
|
|
132
|
+
return;
|
|
133
|
+
if (parent === null || parent.archivedAt === null)
|
|
134
|
+
return;
|
|
135
|
+
throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
|
|
136
|
+
}
|
|
137
|
+
function refuseWrongParent(childKind, parent, boards) {
|
|
87
138
|
if (childKind === "task") {
|
|
88
139
|
if (parent === null) {
|
|
89
140
|
throw new IntelError(409, "task_belongs_to_a_board", "A task lives on a board. The top level is not one, so there is nowhere for it to sit there.");
|
|
90
141
|
}
|
|
91
|
-
|
|
142
|
+
// ⚠️ Since #669 a task may sit under a task — the hierarchy is the node tree. Everything else
|
|
143
|
+
// is still refused by name.
|
|
144
|
+
if (parent.kind !== "board" && parent.kind !== "task") {
|
|
92
145
|
throw new IntelError(409, "task_belongs_to_a_board", `A task lives on a board and cannot be filed under a ${parent.kind}. Move the board instead, or make a new task on the board you want it on.`);
|
|
93
146
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
/**
|
|
148
|
+
* ⚠️ **The same BOARD, not the same parent node — and that difference is what #669 changed.**
|
|
149
|
+
* Until then the two were interchangeable, because a task's parent always WAS its board; the
|
|
150
|
+
* check compared parent ids and was right by accident. With nesting, moving a card under a
|
|
151
|
+
* sibling changes its parent legitimately while the board stays the same, and comparing ids
|
|
152
|
+
* would refuse a move that is allowed.
|
|
153
|
+
*
|
|
154
|
+
* The rule it protects is unchanged: `board_tasks.board_id` does not travel with a move through
|
|
155
|
+
* the node path, so a card handed to another board would answer from one board's query while
|
|
156
|
+
* its row names the other — and the card would show up on neither, or on both.
|
|
157
|
+
*/
|
|
158
|
+
if (boards?.destination === null || boards?.destination === undefined) {
|
|
159
|
+
throw new IntelError(409, "task_belongs_to_a_board", "This destination has no board, so a card cannot be filed under it.");
|
|
160
|
+
}
|
|
161
|
+
if (boards.current !== undefined &&
|
|
162
|
+
boards.current !== null &&
|
|
163
|
+
boards.destination !== boards.current) {
|
|
100
164
|
throw new IntelError(409, "task_cannot_change_board", "A card cannot be handed to another board this way. Make it on the board you want it on; its position, status and dependencies belong to the board it was filed on.");
|
|
101
165
|
}
|
|
102
166
|
return;
|
|
@@ -512,7 +576,12 @@ export function createNodes(deps) {
|
|
|
512
576
|
* than in a sweeper somebody has to remember to write.
|
|
513
577
|
*/
|
|
514
578
|
if (existing.kind === "task" && existing.parentId !== null) {
|
|
515
|
-
|
|
579
|
+
// ⚠️ The BOARD, resolved the same way the create path resolves it. Passing `parentId`
|
|
580
|
+
// straight through was right only while a task's parent was always its board.
|
|
581
|
+
const board = await boardFor(deps, actor, await requireVisible(actor, existing.parentId));
|
|
582
|
+
if (board !== null) {
|
|
583
|
+
await deps.attachToBoard?.(actor, existing.id, board, deps.now().toISOString());
|
|
584
|
+
}
|
|
516
585
|
}
|
|
517
586
|
return existing;
|
|
518
587
|
}
|
|
@@ -522,7 +591,13 @@ export function createNodes(deps) {
|
|
|
522
591
|
// ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
|
|
523
592
|
// parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
|
|
524
593
|
// `attachToBoard` skips it for the same reason, and the card belongs to no board at all.
|
|
525
|
-
|
|
594
|
+
const destinationParent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
|
|
595
|
+
const destinationBoard = await boardFor(deps, actor, destinationParent);
|
|
596
|
+
// ⚠️ Kind first, archive second. A task under an archived FOLDER is refused for the deeper
|
|
597
|
+
// reason — a task lives on a board — and saying "restore the folder first" would send the
|
|
598
|
+
// reader to do something that changes nothing: restored, the folder still cannot hold a task.
|
|
599
|
+
refuseWrongParent(input.kind, destinationParent, { destination: destinationBoard });
|
|
600
|
+
refuseArchivedParent(input.kind, destinationParent);
|
|
526
601
|
const timestamp = deps.now().toISOString();
|
|
527
602
|
const created = await deps.repository.insertNode({
|
|
528
603
|
node: {
|
|
@@ -556,8 +631,10 @@ export function createNodes(deps) {
|
|
|
556
631
|
* The port is optional because the node service must not require a board to exist: the CLI
|
|
557
632
|
* and the bundle importer build one without that half of the world.
|
|
558
633
|
*/
|
|
559
|
-
|
|
560
|
-
|
|
634
|
+
// ⚠️ `destinationBoard`, not `input.parentId`. Since #669 the parent may be another task, and
|
|
635
|
+
// filing the card under its parent's ID would put a subtask on a "board" that is a card.
|
|
636
|
+
if (created.kind === "task" && destinationBoard !== null) {
|
|
637
|
+
await deps.attachToBoard?.(actor, created.id, destinationBoard, timestamp);
|
|
561
638
|
}
|
|
562
639
|
return created;
|
|
563
640
|
},
|
|
@@ -878,10 +955,35 @@ export function createNodes(deps) {
|
|
|
878
955
|
throw new IntelError(409, "move_cycle", "A node cannot contain itself");
|
|
879
956
|
}
|
|
880
957
|
const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
|
|
881
|
-
|
|
958
|
+
/**
|
|
959
|
+
* ⚠️ **The write check on the destination comes FIRST, before the board is resolved.**
|
|
960
|
+
* `boardOfTask` asks with the verb `write`, so for a destination this actor may only read
|
|
961
|
+
* it answers `null` — and the refusal below would then say "this destination has no board"
|
|
962
|
+
* about a card that has one perfectly well. The honest answer is 403, and it is the order
|
|
963
|
+
* `packages/api/CLAUDE.md` prescribes anyway: capability, then resource, then action.
|
|
964
|
+
*/
|
|
882
965
|
if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
|
|
883
966
|
throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
|
|
884
967
|
}
|
|
968
|
+
const destinationBoard = await boardFor(deps, actor, parent);
|
|
969
|
+
/**
|
|
970
|
+
* ⚠️ **A board that cannot be resolved is refused, not skipped.** Without this line an
|
|
971
|
+
* installation whose board port is absent — or one where the actor may not write the
|
|
972
|
+
* board — would answer `undefined`, the comparison below would read that as "not a move
|
|
973
|
+
* between boards", and a card could be handed to any board at all. The integration test
|
|
974
|
+
* `refuses the three ways a card could leave its board` caught exactly that: the test
|
|
975
|
+
* harness did not wire the port, and door 3 stood open again.
|
|
976
|
+
*/
|
|
977
|
+
const currentBoard = current.kind === "task" ? ((await deps.boardOfTask?.(actor, current.id)) ?? null) : null;
|
|
978
|
+
if (current.kind === "task" && currentBoard === null) {
|
|
979
|
+
throw new IntelError(409, "task_cannot_change_board", "This card's board cannot be resolved, so it cannot be moved. A card belongs to the board it was filed on.");
|
|
980
|
+
}
|
|
981
|
+
// Kind first, archive second — see the same pair in `create`.
|
|
982
|
+
refuseWrongParent(current.kind, parent, {
|
|
983
|
+
destination: destinationBoard,
|
|
984
|
+
current: currentBoard,
|
|
985
|
+
});
|
|
986
|
+
refuseArchivedParent(current.kind, parent);
|
|
885
987
|
}
|
|
886
988
|
const updatedAt = deps.now().toISOString();
|
|
887
989
|
const updated = await deps.repository.updateNode({
|
|
@@ -929,6 +1031,27 @@ export function createNodes(deps) {
|
|
|
929
1031
|
// A replay does nothing a second time and answers with what the first run made.
|
|
930
1032
|
if (replayedId)
|
|
931
1033
|
return await requireVisible(actor, replayedId);
|
|
1034
|
+
/**
|
|
1035
|
+
* ⚠️ **A task with live subtasks is refused, not archived with them** (#669).
|
|
1036
|
+
* `destructive.md`: *verweigern schlägt kaskadieren* — a cascade turns one confirmed click
|
|
1037
|
+
* into an unknown number of disappearances, and the question that was asked ("archive this
|
|
1038
|
+
* card") cannot then name what actually vanishes.
|
|
1039
|
+
*
|
|
1040
|
+
* ⚠️ It is refused only ON THE WAY IN. Restoring a parent must stay possible, or a card
|
|
1041
|
+
* archived together with its children by an older path could never come back.
|
|
1042
|
+
*
|
|
1043
|
+
* The other half — `purge` — needs no rule of its own, but ONLY together with
|
|
1044
|
+
* `refuseArchivedParent` below: only an archived node may be purged, a parent cannot reach
|
|
1045
|
+
* that state while a child is live, and nothing new may be filed under it afterwards. Take
|
|
1046
|
+
* any one of the three away and a live card can be deleted for good without being named —
|
|
1047
|
+
* which is what the review of #669 found in the first version of this sentence.
|
|
1048
|
+
*/
|
|
1049
|
+
if (input.archived && current.kind === "task") {
|
|
1050
|
+
const children = await deps.repository.countLiveChildren(current.id);
|
|
1051
|
+
if (children > 0) {
|
|
1052
|
+
throw new IntelError(409, "task_has_subtasks", `This card still has ${children} subtask(s). Archive them first, or move them somewhere else — archiving a card does not archive what hangs under it.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
932
1055
|
const updatedAt = deps.now().toISOString();
|
|
933
1056
|
const updated = await deps.repository.archiveNode({
|
|
934
1057
|
nodeId: current.id,
|
|
@@ -55,6 +55,23 @@ export interface NodeRepository {
|
|
|
55
55
|
limit: number;
|
|
56
56
|
}): Promise<BoundedChildren>;
|
|
57
57
|
getVisible(actor: Actor, nodeId: string): Promise<Node | null>;
|
|
58
|
+
/**
|
|
59
|
+
* How many live children this node has — **every kind, tasks included** (#669).
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **`listVisibleBounded` cannot answer this**, and the difference is invisible at the call
|
|
62
|
+
* site: that query runs through `visibleChildren`, which cuts `kind = 'task'` out of every level
|
|
63
|
+
* so a board shows no cards in the tree (D66). Asked about a task, it therefore answers `0` for
|
|
64
|
+
* every task that ever existed — a guard built on it would never fire once, and nothing would
|
|
65
|
+
* report that. Two queries with similar names are two different questions
|
|
66
|
+
* (`destructive.md`, `anchrd/intel#457`).
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ **It takes no actor, and that is the second half of the same lesson.** A grant can sit on a
|
|
69
|
+
* single node (ADR-0004 §2), so a child may be invisible to whoever archives its parent. Counted
|
|
70
|
+
* through the visibility predicate, such a child reads as `0`, the refusal does not fire, the
|
|
71
|
+
* parent becomes purgeable — and the child is deleted for good without ever being named. The
|
|
72
|
+
* question here is "is anything still hanging under this node", not "what may this actor see".
|
|
73
|
+
*/
|
|
74
|
+
countLiveChildren(nodeId: string): Promise<number>;
|
|
58
75
|
listVisibleSubtree(actor: Actor, rootId: string | null): Promise<SubtreeNode[]>;
|
|
59
76
|
can(actor: Actor, nodeId: string, verb: ResourceVerb): Promise<boolean>;
|
|
60
77
|
findIdempotentNode(actorId: string, operation: "node.create" | "node.save" | "node.append" | "node.update" | "node.archive" | "node.share" | "node.revoke" | SnapshotOperation, idempotencyKey: string): Promise<string | null>;
|
|
@@ -223,6 +240,18 @@ export interface NodesDeps {
|
|
|
223
240
|
* and gets filed later, never that the node is refused.
|
|
224
241
|
*/
|
|
225
242
|
attachToBoard?(actor: Actor, taskId: string, boardId: string, occurredAt: string): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* Which board a task belongs to (#669).
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ Needed since a task may sit under another task: the destination of a move is then not the
|
|
247
|
+
* board itself, and "does this card change boards" cannot be answered from the node row alone.
|
|
248
|
+
*
|
|
249
|
+
* Optional for the same reason as `attachToBoard` — the CLI and the bundle importer have no
|
|
250
|
+
* board half. ⚠️ **Where it is absent, filing a task UNDER a task is refused** rather than
|
|
251
|
+
* allowed unchecked: the rule it would skip is the one that keeps a card from silently changing
|
|
252
|
+
* boards.
|
|
253
|
+
*/
|
|
254
|
+
boardOfTask?(actor: Actor, taskId: string): Promise<string | null>;
|
|
226
255
|
content: ContentStore;
|
|
227
256
|
id(): string;
|
|
228
257
|
now(): Date;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- The archive as a column every board has (D68, #674).
|
|
2
|
+
--
|
|
3
|
+
-- ⚠️ Only the VISIBILITY is stored. Which cards are in the archive is `nodes.archived_at` and
|
|
4
|
+
-- nothing else — a second column holding "is archived" would be the two truths D68 exists against.
|
|
5
|
+
-- A card is in the archive column BECAUSE it is archived; dropping it there archives it, and
|
|
6
|
+
-- pulling it out restores it into the working column `board_tasks.status` still names.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ Default 1: the column is there on every board unless somebody hides it. A board that predates
|
|
9
|
+
-- this migration therefore shows it too, which is the decided behaviour rather than an accident —
|
|
10
|
+
-- an archive nobody can see is an archive nobody empties.
|
|
11
|
+
ALTER TABLE boards ADD COLUMN archive_visible INTEGER NOT NULL DEFAULT 1;
|
|
12
|
+
|
|
13
|
+
-- ⚠️ **Existing data.** Until this migration `BoardColumn.id` was a free string: a board could
|
|
14
|
+
-- configure a column called `archived` itself. From here the name is reserved, and without this
|
|
15
|
+
-- step such a board would show TWO columns with one id — the stored one and the derived one — and
|
|
16
|
+
-- the cards of both would be indistinguishable.
|
|
17
|
+
--
|
|
18
|
+
-- ⚠️ **Every row with that status, not only the ones whose board still configures it.** Columns are
|
|
19
|
+
-- REPLACED rather than merged, so a board may have used the column and dropped it again; those
|
|
20
|
+
-- cards still carry `status = 'archived'` while `statuses_json` has long forgotten it. Left alone
|
|
21
|
+
-- they would surface in the archive column with `archived_at` still `null` — a card that looks
|
|
22
|
+
-- archived, is not, and cannot be restored, because there is nothing to restore.
|
|
23
|
+
--
|
|
24
|
+
-- The new name is deliberately ugly, because it is meant to be seen: whoever reads it knows
|
|
25
|
+
-- something was renamed here and can put it right in the settings.
|
|
26
|
+
UPDATE board_tasks SET status = 'archived_column_renamed' WHERE status = 'archived';
|
|
27
|
+
|
|
28
|
+
UPDATE boards
|
|
29
|
+
SET statuses_json = replace(statuses_json, '"id":"archived"', '"id":"archived_column_renamed"')
|
|
30
|
+
WHERE node_id IN (
|
|
31
|
+
SELECT b.node_id FROM boards b, json_each(b.statuses_json)
|
|
32
|
+
WHERE json_extract(json_each.value, '$.id') = 'archived'
|
|
33
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.21.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.24.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|