@anchrd/intel-api 0.26.0 → 0.28.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 +34 -0
- package/dist/adapters/db/db-audit.d.ts +34 -0
- package/dist/adapters/db/db-audit.js +89 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +201 -0
- package/dist/adapters/db/db.js +22 -2
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +72 -0
- package/dist/audit/audit.types.d.ts +22 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/boards/boards.d.ts +16 -0
- package/dist/boards/boards.js +166 -0
- package/dist/boards/boards.types.d.ts +51 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.js +10 -1
- package/dist/http/http.js +67 -0
- package/dist/http/http.types.d.ts +4 -0
- package/dist/intel/intel.js +4 -0
- package/dist/intel/intel.types.d.ts +4 -0
- package/dist/mcp/mcp.js +72 -0
- package/dist/mcp/mcp.types.d.ts +4 -0
- package/dist/nodes/nodes.js +101 -9
- package/dist/nodes/nodes.types.d.ts +10 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/package.json +3 -3
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
|
+
// What a board with no configuration answers with. A board is usable the moment it is created, and
|
|
3
|
+
// nothing asks whoever made it to define columns before filing the first card.
|
|
4
|
+
export const DEFAULT_COLUMNS = [
|
|
5
|
+
{ id: "todo", title: "Offen", terminal: false },
|
|
6
|
+
{ id: "doing", title: "Läuft", terminal: false },
|
|
7
|
+
{ id: "done", title: "Fertig", terminal: true },
|
|
8
|
+
];
|
|
9
|
+
// The column a card lands in when nobody said and the board has none configured. One export rather
|
|
10
|
+
// than the literal `"todo"` in three places: the fallback exists FOR the unconfigured board, so a
|
|
11
|
+
// copy of it would drift exactly where it is the only thing deciding.
|
|
12
|
+
export const FIRST_DEFAULT_COLUMN = DEFAULT_COLUMNS[0].id;
|
|
13
|
+
export function createBoards(deps) {
|
|
14
|
+
/**
|
|
15
|
+
* ⚠️ The one place "which board, and may this actor DO THIS to it" is answered — and the verb is
|
|
16
|
+
* the whole question. It asked `read` for every caller once, including the three that were about
|
|
17
|
+
* to write: a board shared read-only could have its columns replaced and its cards moved. The
|
|
18
|
+
* refusal existed and asked the wrong thing, which is the harder failure to see.
|
|
19
|
+
*
|
|
20
|
+
* A `404` rather than a `403`, on purpose: "this board exists but you may not touch it" is itself
|
|
21
|
+
* information about somebody else's tree.
|
|
22
|
+
*/
|
|
23
|
+
const columnsOrRefuse = async (actor, boardId, verb) => {
|
|
24
|
+
const columns = await deps.boards.columnsOf(actor, boardId, verb);
|
|
25
|
+
if (columns === null) {
|
|
26
|
+
throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
|
|
27
|
+
}
|
|
28
|
+
return columns.length > 0 ? columns : DEFAULT_COLUMNS.map((column) => column.id);
|
|
29
|
+
};
|
|
30
|
+
const viewOrRefuse = async (actor, input) => {
|
|
31
|
+
const view = await deps.boards.read(actor, input);
|
|
32
|
+
if (view === null) {
|
|
33
|
+
throw new IntelError(404, "board_not_found", "No board with this id is readable for you.");
|
|
34
|
+
}
|
|
35
|
+
// A board that was never configured answers with the default columns rather than none. An empty
|
|
36
|
+
// column list would render as a board with no columns at all — a screen with nowhere to put a
|
|
37
|
+
// card, which looks broken rather than new.
|
|
38
|
+
return view.columns.length > 0 ? view : { ...view, columns: [...DEFAULT_COLUMNS] };
|
|
39
|
+
};
|
|
40
|
+
const requireColumn = (columns, status) => {
|
|
41
|
+
if (columns.includes(status))
|
|
42
|
+
return;
|
|
43
|
+
throw new IntelError(400, "unknown_column", `This board has no column \`${status}\`. Its columns are: ${columns.join(", ")}.`);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
async get(actor, input) {
|
|
47
|
+
return await viewOrRefuse(actor, input);
|
|
48
|
+
},
|
|
49
|
+
async update(actor, input) {
|
|
50
|
+
await columnsOrRefuse(actor, input.boardId, "write");
|
|
51
|
+
// ⚠️ Duplicate ids are refused rather than deduplicated. Two columns with one id means every
|
|
52
|
+
// card in them lands in whichever the view draws first, and silently dropping one would
|
|
53
|
+
// discard a column somebody just wrote.
|
|
54
|
+
const ids = input.columns.map((column) => column.id);
|
|
55
|
+
const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
|
|
56
|
+
if (duplicate !== undefined) {
|
|
57
|
+
throw new IntelError(400, "duplicate_column", `The column id \`${duplicate}\` appears more than once.`);
|
|
58
|
+
}
|
|
59
|
+
await deps.boards.setColumns(input.boardId, JSON.stringify(input.columns), deps.now().toISOString());
|
|
60
|
+
return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
|
|
61
|
+
},
|
|
62
|
+
async createTask(actor, input) {
|
|
63
|
+
const columns = await columnsOrRefuse(actor, input.boardId, "write");
|
|
64
|
+
// Absent means the first column — a caller who does not care where a task starts should not
|
|
65
|
+
// have to read the board first. A named one is checked, because a typo would otherwise create
|
|
66
|
+
// a card in a column no view draws.
|
|
67
|
+
const status = input.status ?? columns[0] ?? DEFAULT_COLUMNS[0].id;
|
|
68
|
+
if (input.status !== undefined)
|
|
69
|
+
requireColumn(columns, status);
|
|
70
|
+
// ⚠️ Through the node service, not a second insert. The task gets the same authorization
|
|
71
|
+
// check, the same audit row and the same version chain every other node gets — and that audit
|
|
72
|
+
// row is what `anchrd/signals` reads (#620).
|
|
73
|
+
const node = await deps.nodes.create(actor, {
|
|
74
|
+
parentId: input.boardId,
|
|
75
|
+
kind: "task",
|
|
76
|
+
title: input.title,
|
|
77
|
+
description: null,
|
|
78
|
+
idempotencyKey: input.idempotencyKey,
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* ⚠️ **`update`, not `attach` — the row already exists by the time this line runs**, and
|
|
82
|
+
* getting that wrong is silent. `nodes.create` files every new task through `attachToBoard`,
|
|
83
|
+
* with the board's first column and no fields; a second `INSERT` here is refused by the
|
|
84
|
+
* primary key, `ON CONFLICT DO NOTHING` swallows the refusal, and the caller's `status`,
|
|
85
|
+
* assignee and dates are **discarded without an error**. The card lands in the wrong column
|
|
86
|
+
* and nothing anywhere says why.
|
|
87
|
+
*
|
|
88
|
+
* It was written as an insert first, and three tests caught it: a card created in `doing`
|
|
89
|
+
* came back in `todo`. The lesson is the shape rather than the line — one row, one writer,
|
|
90
|
+
* and the writer is the node path.
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* ⚠️ **The position is computed only when the card is not already in that column**, and that
|
|
94
|
+
* is what makes a retry safe. `deps.nodes.create` honours the idempotency key and answers with
|
|
95
|
+
* the SAME node on a replay; recomputing here would push the card one place further on every
|
|
96
|
+
* attempt — a client retrying a timeout would watch its card walk down the column, with every
|
|
97
|
+
* call succeeding.
|
|
98
|
+
*
|
|
99
|
+
* The other fields are rewritten with the same values, which is idempotent by construction.
|
|
100
|
+
*/
|
|
101
|
+
const filed = await deps.boards.taskRow(actor, node.id, "write");
|
|
102
|
+
await deps.boards.updateTask(node.id, {
|
|
103
|
+
status,
|
|
104
|
+
assignee_id: input.assigneeId,
|
|
105
|
+
labels_json: JSON.stringify(input.labels),
|
|
106
|
+
start_date: input.startDate,
|
|
107
|
+
due_date: input.dueDate,
|
|
108
|
+
depends_on: input.dependsOn,
|
|
109
|
+
...(filed?.status === status
|
|
110
|
+
? {}
|
|
111
|
+
: { position: await deps.boards.nextPosition(input.boardId, status) }),
|
|
112
|
+
}, deps.now().toISOString());
|
|
113
|
+
return await viewOrRefuse(actor, { boardId: input.boardId, includeArchived: false });
|
|
114
|
+
},
|
|
115
|
+
async updateTask(actor, input) {
|
|
116
|
+
const row = await deps.boards.taskRow(actor, input.taskId, "write");
|
|
117
|
+
if (row === null) {
|
|
118
|
+
throw new IntelError(404, "task_not_found", "No task with this id is readable for you.");
|
|
119
|
+
}
|
|
120
|
+
if (input.status !== undefined) {
|
|
121
|
+
requireColumn(await columnsOrRefuse(actor, row.boardId, "write"), input.status);
|
|
122
|
+
}
|
|
123
|
+
// Only what arrived. An absent field means "leave it"; `null` where the column is nullable
|
|
124
|
+
// means "clear it", which is why the check is against `undefined` and not against falsiness —
|
|
125
|
+
// `dueDate: null` and `position: 0` both have to get through.
|
|
126
|
+
const fields = {};
|
|
127
|
+
if (input.title !== undefined) {
|
|
128
|
+
// ⚠️ The title lives on the NODE, not here. `board_tasks` has no title column, and adding
|
|
129
|
+
// one would make two rows disagree about what a card is called.
|
|
130
|
+
throw new IntelError(400, "title_belongs_to_the_node", "Rename a task with `node_update`; the board row carries no title.");
|
|
131
|
+
}
|
|
132
|
+
if (input.status !== undefined)
|
|
133
|
+
fields.status = input.status;
|
|
134
|
+
if (input.position !== undefined)
|
|
135
|
+
fields.position = input.position;
|
|
136
|
+
if (input.assigneeId !== undefined)
|
|
137
|
+
fields.assignee_id = input.assigneeId;
|
|
138
|
+
if (input.labels !== undefined)
|
|
139
|
+
fields.labels_json = JSON.stringify(input.labels);
|
|
140
|
+
if (input.startDate !== undefined)
|
|
141
|
+
fields.start_date = input.startDate;
|
|
142
|
+
if (input.dueDate !== undefined)
|
|
143
|
+
fields.due_date = input.dueDate;
|
|
144
|
+
if (input.dependsOn !== undefined)
|
|
145
|
+
fields.depends_on = input.dependsOn;
|
|
146
|
+
/**
|
|
147
|
+
* A move that names a new column but no position lands at the end of it — otherwise the card
|
|
148
|
+
* would keep the number it had in its old column and appear at an arbitrary place.
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ **Only when the column actually CHANGES.** Asking again for the column a card is already
|
|
151
|
+
* in is what a retry looks like, and recomputing there would move the card one place further
|
|
152
|
+
* on every attempt while every call reported success. That is the idempotency the tool
|
|
153
|
+
* promises with `idempotentHint: true`, kept by construction rather than by a key lookup.
|
|
154
|
+
*/
|
|
155
|
+
if (input.status !== undefined &&
|
|
156
|
+
input.position === undefined &&
|
|
157
|
+
row.status !== input.status) {
|
|
158
|
+
fields.position = await deps.boards.nextPosition(row.boardId, input.status);
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(fields).length > 0) {
|
|
161
|
+
await deps.boards.updateTask(input.taskId, fields, deps.now().toISOString());
|
|
162
|
+
}
|
|
163
|
+
return await viewOrRefuse(actor, { boardId: row.boardId, includeArchived: false });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, BoardView } from "@anchrd/intel-contract/board";
|
|
2
|
+
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
|
+
import type { Actor } from "../nodes/nodes.types.js";
|
|
4
|
+
export type { BoardView };
|
|
5
|
+
export interface BoardTaskRow {
|
|
6
|
+
nodeId: string;
|
|
7
|
+
boardId: string;
|
|
8
|
+
status: string;
|
|
9
|
+
position: number;
|
|
10
|
+
}
|
|
11
|
+
export interface BoardTaskWrite {
|
|
12
|
+
nodeId: string;
|
|
13
|
+
boardId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
assigneeId: string | null;
|
|
16
|
+
labels: string[];
|
|
17
|
+
startDate: string | null;
|
|
18
|
+
dueDate: string | null;
|
|
19
|
+
dependsOn: string | null;
|
|
20
|
+
position: number;
|
|
21
|
+
occurredAt: string;
|
|
22
|
+
}
|
|
23
|
+
export interface BoardRepository {
|
|
24
|
+
read(actor: Actor, input: BoardGetInput): Promise<BoardView | null>;
|
|
25
|
+
columnsOf(actor: Actor, boardId: string, verb: "read" | "write"): Promise<string[] | null>;
|
|
26
|
+
setColumns(boardId: string, columnsJson: string, occurredAt: string): Promise<void>;
|
|
27
|
+
attach(write: BoardTaskWrite): Promise<void>;
|
|
28
|
+
taskRow(actor: Actor, taskId: string, verb: "read" | "write"): Promise<BoardTaskRow | null>;
|
|
29
|
+
updateTask(taskId: string, fields: Record<string, string | number | null>, occurredAt: string): Promise<void>;
|
|
30
|
+
nextPosition(boardId: string, status: string): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
export interface BoardNodePort {
|
|
33
|
+
create(actor: Actor, input: {
|
|
34
|
+
parentId: string;
|
|
35
|
+
kind: "task";
|
|
36
|
+
title: string;
|
|
37
|
+
description: null;
|
|
38
|
+
idempotencyKey: string;
|
|
39
|
+
}): Promise<Node>;
|
|
40
|
+
}
|
|
41
|
+
export interface BoardDeps {
|
|
42
|
+
boards: BoardRepository;
|
|
43
|
+
nodes: BoardNodePort;
|
|
44
|
+
now(): Date;
|
|
45
|
+
}
|
|
46
|
+
export interface BoardService {
|
|
47
|
+
get(actor: Actor, input: BoardGetInput): Promise<BoardView>;
|
|
48
|
+
update(actor: Actor, input: BoardUpdateInput): Promise<BoardView>;
|
|
49
|
+
createTask(actor: Actor, input: BoardTaskCreateInput): Promise<BoardView>;
|
|
50
|
+
updateTask(actor: Actor, input: BoardTaskUpdateInput): Promise<BoardView>;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/build/build.js
CHANGED
|
@@ -18,11 +18,20 @@ const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
|
|
|
18
18
|
//
|
|
19
19
|
// Without it every asset answers with the platform default `public, max-age=0, must-revalidate`,
|
|
20
20
|
// and `public` lets a browser store an ERROR answer too. A `403` that appears once during setup is
|
|
21
|
-
// then replayed on
|
|
21
|
+
// then replayed on BACK/FORWARD WITHOUT a request leaving the browser — invisible in the worker
|
|
22
22
|
// log, in the Cloudflare Access log, in the firewall and in Cloudflare Analytics, while `curl`
|
|
23
23
|
// against the same address keeps answering `200`. The second rule matters more here than in Gate:
|
|
24
24
|
// the UI ships 1.9 MB of fingerprinted assets that are revalidated on every navigation today.
|
|
25
25
|
//
|
|
26
|
+
// ⚠️ The ERROR answer is NOT replayed on every navigation — that is the asset line right above, a
|
|
27
|
+
// different statement about `/assets/*`, and it stays as it is. The narrower shape is what makes
|
|
28
|
+
// this expensive to diagnose: `must-revalidate` DOES hold for a fresh navigation, so retyping the
|
|
29
|
+
// address returns the REPAIRED page (`200 /` in the worker log), while a history navigation replays
|
|
30
|
+
// the stored answer without any freshness check (`transferSize: 0`). Measured in Chromium with a
|
|
31
|
+
// real cache, anchrd/gate#399. Whoever retypes the address rules the browser cache out and loses
|
|
32
|
+
// the afternoon of anchrd/gate#329 a second time — going BACK is the only handle that shows the
|
|
33
|
+
// frozen answer.
|
|
34
|
+
//
|
|
26
35
|
// ⚠️ `! Cache-Control` is not decoration, and the naive two-rule form from the ticket is WRONG.
|
|
27
36
|
// A request matching several rules inherits ALL their headers, and a header named twice is joined
|
|
28
37
|
// with a comma. Measured against `wrangler dev` 4.112.0 with both spellings side by side:
|
package/dist/http/http.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AuditListRequest } from "@anchrd/intel-contract/audit";
|
|
2
|
+
import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
|
|
1
3
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
2
4
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
3
5
|
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
@@ -204,6 +206,71 @@ export function createHttp(deps) {
|
|
|
204
206
|
const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
|
|
205
207
|
return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
|
|
206
208
|
});
|
|
209
|
+
// The change journal, read forward from a stable position (#620). `nodes/read` and nothing new:
|
|
210
|
+
// an audit row about a node says the same thing the node says, so a second capability would be a
|
|
211
|
+
// second answer to one question. The per-row check is the service's, and it is the same tree walk
|
|
212
|
+
// the node reads go through.
|
|
213
|
+
//
|
|
214
|
+
// ⚠️ GET with the cursor in the query string, because the verb is one of the four in the grammar
|
|
215
|
+
// and the method carries it. `limit` arrives as a string and is coerced here — the contract
|
|
216
|
+
// wants a number, and a query string has none.
|
|
217
|
+
app.get("/audit", async (context) => {
|
|
218
|
+
const auth = requireCapability(context, "nodes", "read");
|
|
219
|
+
const limit = context.req.query("limit");
|
|
220
|
+
const after = context.req.query("after");
|
|
221
|
+
const input = AuditListRequest.parse({
|
|
222
|
+
resourceType: context.req.query("resourceType"),
|
|
223
|
+
...(after === undefined ? {} : { after }),
|
|
224
|
+
...(limit === undefined ? {} : { limit: Number(limit) }),
|
|
225
|
+
});
|
|
226
|
+
return context.json(await deps.audit.list(asActor(auth), input));
|
|
227
|
+
});
|
|
228
|
+
// The whole board in one answer (#648). `nodes/read` and nothing new: a board is a node, and a
|
|
229
|
+
// card is a node — a second capability would be a second answer to one question.
|
|
230
|
+
//
|
|
231
|
+
// ⚠️ Every filter is a query parameter and every one of them reaches the WHERE. Filtering after
|
|
232
|
+
// the read would have carried other people's cards over the wire first.
|
|
233
|
+
app.get("/boards/:boardId", async (context) => {
|
|
234
|
+
const auth = requireCapability(context, "nodes", "read");
|
|
235
|
+
const query = context.req.query();
|
|
236
|
+
const input = BoardGetInput.parse({
|
|
237
|
+
boardId: context.req.param("boardId"),
|
|
238
|
+
...(query.status === undefined ? {} : { status: query.status }),
|
|
239
|
+
...(query.assigneeId === undefined ? {} : { assigneeId: query.assigneeId }),
|
|
240
|
+
...(query.dueBefore === undefined ? {} : { dueBefore: query.dueBefore }),
|
|
241
|
+
...(query.dependsOn === undefined ? {} : { dependsOn: query.dependsOn }),
|
|
242
|
+
...(query.includeArchived === undefined
|
|
243
|
+
? {}
|
|
244
|
+
: { includeArchived: query.includeArchived === "true" }),
|
|
245
|
+
});
|
|
246
|
+
return context.json(await deps.boards.get(asActor(auth), input));
|
|
247
|
+
});
|
|
248
|
+
app.patch("/boards/:boardId", async (context) => {
|
|
249
|
+
const auth = requireCapability(context, "nodes", "write");
|
|
250
|
+
const input = BoardUpdateInput.parse(await context.req.json().catch(() => null));
|
|
251
|
+
if (input.boardId !== context.req.param("boardId")) {
|
|
252
|
+
throw new IntelError(400, "board_id_mismatch", "Path and body board IDs differ");
|
|
253
|
+
}
|
|
254
|
+
return context.json(await deps.boards.update(asActor(auth), input));
|
|
255
|
+
});
|
|
256
|
+
app.post("/boards/:boardId/tasks", async (context) => {
|
|
257
|
+
const auth = requireCapability(context, "nodes", "create");
|
|
258
|
+
const input = BoardTaskCreateInput.parse(await context.req.json().catch(() => null));
|
|
259
|
+
if (input.boardId !== context.req.param("boardId")) {
|
|
260
|
+
throw new IntelError(400, "board_id_mismatch", "Path and body board IDs differ");
|
|
261
|
+
}
|
|
262
|
+
return context.json(await deps.boards.createTask(asActor(auth), input), 201);
|
|
263
|
+
});
|
|
264
|
+
// ⚠️ `PATCH` on the task, not `POST …/move`. Moving a card IS an update of `status` and
|
|
265
|
+
// `position`; a second address for it would be a second way to write one row.
|
|
266
|
+
app.patch("/boards/:boardId/tasks/:taskId", async (context) => {
|
|
267
|
+
const auth = requireCapability(context, "nodes", "write");
|
|
268
|
+
const input = BoardTaskUpdateInput.parse(await context.req.json().catch(() => null));
|
|
269
|
+
if (input.taskId !== context.req.param("taskId")) {
|
|
270
|
+
throw new IntelError(400, "task_id_mismatch", "Path and body task IDs differ");
|
|
271
|
+
}
|
|
272
|
+
return context.json(await deps.boards.updateTask(asActor(auth), input));
|
|
273
|
+
});
|
|
207
274
|
app.get("/nodes/:nodeId/versions", async (context) => {
|
|
208
275
|
const auth = requireCapability(context, "nodes", "read");
|
|
209
276
|
return context.json(await deps.nodes.listVersions(asActor(auth), context.req.param("nodeId")));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { GateClient } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AuditService } from "../audit/audit.types.js";
|
|
2
3
|
import type { BrowserAuth } from "../auth/auth.types.js";
|
|
4
|
+
import type { BoardService } from "../boards/boards.types.js";
|
|
3
5
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
4
6
|
import type { FlowService } from "../flows/flows.types.js";
|
|
5
7
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
@@ -10,6 +12,8 @@ export interface HttpDeps {
|
|
|
10
12
|
flows: FlowService;
|
|
11
13
|
tools: ToolService;
|
|
12
14
|
bundle: BundleService;
|
|
15
|
+
audit: AuditService;
|
|
16
|
+
boards: BoardService;
|
|
13
17
|
resource: string;
|
|
14
18
|
resourceMetadataUrl: string;
|
|
15
19
|
auth?: Pick<BrowserAuth, "resolve">;
|
package/dist/intel/intel.js
CHANGED
|
@@ -134,6 +134,8 @@ export function createIntel(deps) {
|
|
|
134
134
|
flows: deps.flows,
|
|
135
135
|
tools: deps.tools,
|
|
136
136
|
bundle: deps.bundle,
|
|
137
|
+
audit: deps.audit,
|
|
138
|
+
boards: deps.boards,
|
|
137
139
|
resource,
|
|
138
140
|
resourceMetadataUrl,
|
|
139
141
|
...(deps.auth ? { auth: deps.auth } : {}),
|
|
@@ -181,6 +183,8 @@ export function createIntel(deps) {
|
|
|
181
183
|
nodes: deps.nodes,
|
|
182
184
|
tools: deps.tools,
|
|
183
185
|
bundle: deps.bundle,
|
|
186
|
+
audit: deps.audit,
|
|
187
|
+
boards: deps.boards,
|
|
184
188
|
});
|
|
185
189
|
});
|
|
186
190
|
return app;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { GateClient } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AuditService } from "../audit/audit.types.js";
|
|
2
3
|
import type { BrowserAuth } from "../auth/auth.types.js";
|
|
4
|
+
import type { BoardService } from "../boards/boards.types.js";
|
|
3
5
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
4
6
|
import type { FlowService } from "../flows/flows.types.js";
|
|
5
7
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
@@ -12,5 +14,7 @@ export interface IntelDeps {
|
|
|
12
14
|
flows: FlowService;
|
|
13
15
|
tools: ToolService;
|
|
14
16
|
bundle: BundleService;
|
|
17
|
+
audit: AuditService;
|
|
18
|
+
boards: BoardService;
|
|
15
19
|
auth?: BrowserAuth;
|
|
16
20
|
}
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
|
|
2
|
+
import { AuditListRequest } from "@anchrd/intel-contract/audit";
|
|
3
|
+
import { BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
|
|
2
4
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
3
5
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
4
6
|
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
@@ -156,6 +158,76 @@ export async function handleMcp(request, deps) {
|
|
|
156
158
|
name: deps.authorization.identity.name ?? null,
|
|
157
159
|
}));
|
|
158
160
|
if (permits(deps.authorization, "nodes", "read")) {
|
|
161
|
+
// The change journal, read forward from a stable position (#620). It is what makes Intel the
|
|
162
|
+
// simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
|
|
163
|
+
// and Intel never learns that the consumer exists. That is the D24 test passed rather than
|
|
164
|
+
// argued — remove every reader and nothing piles up here.
|
|
165
|
+
//
|
|
166
|
+
// ⚠️ `nodes/read`, not a capability of its own. An audit row about a node says the same thing
|
|
167
|
+
// the node says; a second capability would be a second answer to one question, and the two
|
|
168
|
+
// would drift.
|
|
169
|
+
// The board as one answer (#648). Four tools, not five: a move is an update of `status` and
|
|
170
|
+
// `position`, and deleting a task is `node_archive` — nothing here removes a `nodes` row.
|
|
171
|
+
server.registerTool("board_get", {
|
|
172
|
+
title: "Read a board",
|
|
173
|
+
description: "Read one board in a single answer: its columns and every card you may see, without the card bodies. Filter by column, assignee, due date or what a card waits for — each filter narrows the query itself. Open a card's text with node_version_get.",
|
|
174
|
+
inputSchema: BoardGetInput,
|
|
175
|
+
annotations: {
|
|
176
|
+
title: "Read a board",
|
|
177
|
+
readOnlyHint: true,
|
|
178
|
+
destructiveHint: false,
|
|
179
|
+
idempotentHint: true,
|
|
180
|
+
openWorldHint: false,
|
|
181
|
+
},
|
|
182
|
+
}, async (input) => text(await deps.boards.get(actor, input)));
|
|
183
|
+
server.registerTool("board_task_create", {
|
|
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 — it has its own address, its own permissions and its own history.",
|
|
186
|
+
inputSchema: BoardTaskCreateInput,
|
|
187
|
+
annotations: {
|
|
188
|
+
title: "Add a card to a board",
|
|
189
|
+
readOnlyHint: false,
|
|
190
|
+
destructiveHint: false,
|
|
191
|
+
idempotentHint: false,
|
|
192
|
+
openWorldHint: false,
|
|
193
|
+
},
|
|
194
|
+
}, async (input) => text(await deps.boards.createTask(actor, input)));
|
|
195
|
+
server.registerTool("board_task_update", {
|
|
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.",
|
|
198
|
+
inputSchema: BoardTaskUpdateInput,
|
|
199
|
+
annotations: {
|
|
200
|
+
title: "Change a card",
|
|
201
|
+
readOnlyHint: false,
|
|
202
|
+
destructiveHint: false,
|
|
203
|
+
idempotentHint: true,
|
|
204
|
+
openWorldHint: false,
|
|
205
|
+
},
|
|
206
|
+
}, async (input) => text(await deps.boards.updateTask(actor, input)));
|
|
207
|
+
server.registerTool("board_update", {
|
|
208
|
+
title: "Set a board's columns",
|
|
209
|
+
description: "Replace a board's column list, in order. Columns are replaced rather than merged, so send the complete list. A column marked terminal is one where a card counts as finished.",
|
|
210
|
+
inputSchema: BoardUpdateInput,
|
|
211
|
+
annotations: {
|
|
212
|
+
title: "Set a board's columns",
|
|
213
|
+
readOnlyHint: false,
|
|
214
|
+
destructiveHint: false,
|
|
215
|
+
idempotentHint: true,
|
|
216
|
+
openWorldHint: false,
|
|
217
|
+
},
|
|
218
|
+
}, async (input) => text(await deps.boards.update(actor, input)));
|
|
219
|
+
server.registerTool("audit_list", {
|
|
220
|
+
title: "List change events",
|
|
221
|
+
description: "Read the change journal forward from a stable position: what happened to the nodes you may read, oldest first. Pass `nextCursor` back as `after` to continue where you stopped; a null `nextCursor` means you have caught up. Only node events can be listed — flow events need their own visibility check and are refused rather than left out. Events about permanently deleted nodes are never returned, to anybody.",
|
|
222
|
+
inputSchema: AuditListRequest,
|
|
223
|
+
annotations: {
|
|
224
|
+
title: "List change events",
|
|
225
|
+
readOnlyHint: true,
|
|
226
|
+
destructiveHint: false,
|
|
227
|
+
idempotentHint: true,
|
|
228
|
+
openWorldHint: false,
|
|
229
|
+
},
|
|
230
|
+
}, async (input) => text(await deps.audit.list(actor, input)));
|
|
159
231
|
server.registerTool("node_list", {
|
|
160
232
|
title: "List nodes",
|
|
161
233
|
description: "List authorized folders, documents, attachments, and tables under one parent.",
|
package/dist/mcp/mcp.types.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Authorized } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AuditService } from "../audit/audit.types.js";
|
|
3
|
+
import type { BoardService } from "../boards/boards.types.js";
|
|
2
4
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
3
5
|
import type { FlowService } from "../flows/flows.types.js";
|
|
4
6
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
@@ -14,4 +16,6 @@ export interface McpDeps {
|
|
|
14
16
|
flows: FlowService;
|
|
15
17
|
tools: ToolService;
|
|
16
18
|
bundle: BundleService;
|
|
19
|
+
audit: AuditService;
|
|
20
|
+
boards: BoardService;
|
|
17
21
|
}
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -62,6 +62,55 @@ function decodeBase64(value) {
|
|
|
62
62
|
throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Where a kind may be filed, and it is one rule read by two callers — `create` and the move inside
|
|
67
|
+
* `update` (#377).
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ **Three cases, and the first version had only one of them.** It handled "wrong parent that
|
|
70
|
+
* exists" and left two doors open, both of which produced exactly the state the rule exists against:
|
|
71
|
+
*
|
|
72
|
+
* * **No parent at all.** `parentId: null` is the ROOT, and the root is not a board. The first
|
|
73
|
+
* version skipped the check entirely for `null`, so `node_create({ kind: "task", parentId:
|
|
74
|
+
* null })` made a task with no board — and `attachToBoard` did not run either, for the same
|
|
75
|
+
* reason. A task on no board, created through the front door.
|
|
76
|
+
* * **Right kind, wrong board.** Every `kind === "board"` was accepted as a destination, so a
|
|
77
|
+
* task could be moved to ANOTHER board while its `board_tasks.board_id` stayed behind. That
|
|
78
|
+
* divergence was reachable only by writing D1 by hand before this rule existed (the join in
|
|
79
|
+
* `db-boards.ts` guards against it); the first version of this rule opened a real code path to
|
|
80
|
+
* it. **A change that widens what is allowed has to be read for what it now permits, not only
|
|
81
|
+
* for what it now refuses.**
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ The refusal names the KIND in the sentence rather than a bare "not a folder". Somebody
|
|
84
|
+
* dragging a card into a folder has to learn what went wrong, not that something did.
|
|
85
|
+
*/
|
|
86
|
+
function refuseWrongParent(childKind, parent, currentParentId) {
|
|
87
|
+
if (childKind === "task") {
|
|
88
|
+
if (parent === null) {
|
|
89
|
+
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
|
+
}
|
|
91
|
+
if (parent.kind !== "board") {
|
|
92
|
+
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
|
+
}
|
|
94
|
+
// ⚠️ The same board, not merely a board. `board_tasks.board_id` does not travel with a move
|
|
95
|
+
// through the node path, so a card handed to another board would answer from one board's query
|
|
96
|
+
// while its row names the other — and the card would show up on neither, or on both.
|
|
97
|
+
if (currentParentId !== undefined &&
|
|
98
|
+
currentParentId !== null &&
|
|
99
|
+
parent.id !== currentParentId) {
|
|
100
|
+
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
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// The root takes everything else — that is what a top-level folder or document is.
|
|
105
|
+
if (parent === null)
|
|
106
|
+
return;
|
|
107
|
+
if (parent.kind === "board") {
|
|
108
|
+
throw new IntelError(409, "board_holds_only_tasks", `A board holds tasks and nothing else; a ${childKind} cannot be filed on one.`);
|
|
109
|
+
}
|
|
110
|
+
if (parent.kind !== "folder") {
|
|
111
|
+
throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
65
114
|
export function createNodes(deps) {
|
|
66
115
|
function mergeSearchResults(lexical, semantic, semanticScores, limit) {
|
|
67
116
|
const merged = new Map();
|
|
@@ -446,13 +495,36 @@ export function createNodes(deps) {
|
|
|
446
495
|
},
|
|
447
496
|
async create(actor, input) {
|
|
448
497
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
449
|
-
if (existingId)
|
|
450
|
-
|
|
498
|
+
if (existingId) {
|
|
499
|
+
const existing = await requireVisible(actor, existingId);
|
|
500
|
+
/**
|
|
501
|
+
* ⚠️ **The filing runs on the REPLAY path too, and that is what makes a retry a repair.**
|
|
502
|
+
*
|
|
503
|
+
* `insertNode` commits the node, its idempotency key and its audit row in one batch;
|
|
504
|
+
* `attachToBoard` is a separate statement after it. If that one fails — a transient D1
|
|
505
|
+
* error is enough — the node exists and its board row does not, and `create` throws. The
|
|
506
|
+
* obvious response is the same call again with the same key, and until this line that call
|
|
507
|
+
* returned here **without filing anything**: a task that is a task by kind, sits under a
|
|
508
|
+
* board, and appears on no board, permanently, with nothing reporting it.
|
|
509
|
+
*
|
|
510
|
+
* `attach` is `ON CONFLICT DO NOTHING`, so running it on a node that is already filed costs
|
|
511
|
+
* one statement and changes nothing. That asymmetry is why the repair belongs here rather
|
|
512
|
+
* than in a sweeper somebody has to remember to write.
|
|
513
|
+
*/
|
|
514
|
+
if (existing.kind === "task" && existing.parentId !== null) {
|
|
515
|
+
await deps.attachToBoard?.(actor, existing.id, existing.parentId, deps.now().toISOString());
|
|
516
|
+
}
|
|
517
|
+
return existing;
|
|
518
|
+
}
|
|
451
519
|
if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
|
|
452
520
|
throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
|
|
453
521
|
}
|
|
522
|
+
// ⚠️ The same rule on the way IN, and it runs for the ROOT as well. Guarding only a named
|
|
523
|
+
// parent leaves `parentId: null` open, and that is not a smaller hole: the task is created,
|
|
524
|
+
// `attachToBoard` skips it for the same reason, and the card belongs to no board at all.
|
|
525
|
+
refuseWrongParent(input.kind, input.parentId === null ? null : await requireVisible(actor, input.parentId));
|
|
454
526
|
const timestamp = deps.now().toISOString();
|
|
455
|
-
|
|
527
|
+
const created = await deps.repository.insertNode({
|
|
456
528
|
node: {
|
|
457
529
|
id: deps.id(),
|
|
458
530
|
parentId: input.parentId,
|
|
@@ -469,6 +541,25 @@ export function createNodes(deps) {
|
|
|
469
541
|
idempotencyKey: input.idempotencyKey,
|
|
470
542
|
auditId: deps.id(),
|
|
471
543
|
});
|
|
544
|
+
/**
|
|
545
|
+
* ⚠️ **A task created through the plain node path still gets its board row** (#648). Without
|
|
546
|
+
* this an agent calling `node_create` under a board makes a node that is a task by kind, has
|
|
547
|
+
* no row in `board_tasks`, and therefore appears on NO board — with nothing reporting an
|
|
548
|
+
* error. It is exactly the shape of failure this repository keeps writing rules about: not a
|
|
549
|
+
* refusal, an absence.
|
|
550
|
+
*
|
|
551
|
+
* ⚠️ It runs AFTER the node exists and is its own statement rather than part of the batch.
|
|
552
|
+
* The node is the truth; the board row is derived from it. A pass that dies in between leaves
|
|
553
|
+
* a task nobody filed — visible through `node_get`, repairable by filing it — while the other
|
|
554
|
+
* order would leave a board row pointing at a node that was never written.
|
|
555
|
+
*
|
|
556
|
+
* The port is optional because the node service must not require a board to exist: the CLI
|
|
557
|
+
* and the bundle importer build one without that half of the world.
|
|
558
|
+
*/
|
|
559
|
+
if (created.kind === "task" && input.parentId !== null) {
|
|
560
|
+
await deps.attachToBoard?.(actor, created.id, input.parentId, timestamp);
|
|
561
|
+
}
|
|
562
|
+
return created;
|
|
472
563
|
},
|
|
473
564
|
async save(actor, input) {
|
|
474
565
|
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
@@ -779,15 +870,16 @@ export function createNodes(deps) {
|
|
|
779
870
|
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
|
|
780
871
|
if (replayedId)
|
|
781
872
|
return await requireVisible(actor, replayedId);
|
|
782
|
-
|
|
873
|
+
// ⚠️ `undefined` means "do not move" and is the only value that skips this. An explicit
|
|
874
|
+
// `null` IS a move — to the root — and has to be checked like any other destination; the
|
|
875
|
+
// first version of this treated the two the same and let a card be dragged to the top level.
|
|
876
|
+
if (input.parentId !== undefined) {
|
|
783
877
|
if (input.parentId === current.id) {
|
|
784
878
|
throw new IntelError(409, "move_cycle", "A node cannot contain itself");
|
|
785
879
|
}
|
|
786
|
-
const parent = await requireVisible(actor, input.parentId);
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
if (!(await deps.repository.can(actor, parent.id, "write"))) {
|
|
880
|
+
const parent = input.parentId === null ? null : await requireVisible(actor, input.parentId);
|
|
881
|
+
refuseWrongParent(current.kind, parent, current.parentId);
|
|
882
|
+
if (parent !== null && !(await deps.repository.can(actor, parent.id, "write"))) {
|
|
791
883
|
throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
|
|
792
884
|
}
|
|
793
885
|
}
|
|
@@ -213,6 +213,16 @@ export interface NodeAttachmentBody {
|
|
|
213
213
|
}
|
|
214
214
|
export interface NodesDeps {
|
|
215
215
|
repository: NodeRepository;
|
|
216
|
+
/**
|
|
217
|
+
* Files a freshly created `task` node on the board it was created under (#648).
|
|
218
|
+
*
|
|
219
|
+
* ⚠️ Optional on purpose, and the optionality is the decision rather than a convenience: the node
|
|
220
|
+
* service is the one path every node takes, including in the CLI and the bundle importer, and
|
|
221
|
+
* neither of those has a board half of the world. A required port would make "create a node" fail
|
|
222
|
+
* where boards are not wired — and the answer to a missing board is that a task keeps its node
|
|
223
|
+
* and gets filed later, never that the node is refused.
|
|
224
|
+
*/
|
|
225
|
+
attachToBoard?(actor: Actor, taskId: string, boardId: string, occurredAt: string): Promise<void>;
|
|
216
226
|
content: ContentStore;
|
|
217
227
|
id(): string;
|
|
218
228
|
now(): Date;
|