@anchrd/intel-api 0.12.4 → 0.13.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-api/cloudflare-api.js +18 -1
- package/dist/adapters/openid/openid.js +70 -10
- package/dist/adapters/remote-tools/remote-tools.js +21 -1
- package/dist/bundle/bundle.js +25 -1
- package/dist/indexing/indexing.js +12 -1
- package/dist/mcp/mcp.js +3 -3
- package/dist/nodes/board/board.d.ts +44 -0
- package/dist/nodes/board/board.js +169 -0
- package/dist/nodes/document-links/document-links.js +12 -1
- package/dist/nodes/nodes.js +6 -2
- package/dist/tools/tools.js +7 -1
- package/package.json +2 -2
|
@@ -19,6 +19,23 @@ const ApiOrigin = "https://api.cloudflare.com/client/v4";
|
|
|
19
19
|
*/
|
|
20
20
|
const MaxPages = 20;
|
|
21
21
|
const PerPage = 50;
|
|
22
|
+
/**
|
|
23
|
+
* ⚠️ Cloudflare's ceiling on `/ai/models/search`, and a DIFFERENT number from `PerPage` above —
|
|
24
|
+
* the limit belongs to the endpoint, not to the account. Neither may be copied onto the other's
|
|
25
|
+
* call: 50 on the model catalog halves it, 100 on the log is refused outright.
|
|
26
|
+
*
|
|
27
|
+
* And the two fail in opposite ways, of which this is the worse one. The log endpoint REFUSES with
|
|
28
|
+
* `HTTP 400 Number must be less than or equal to 50` — loud, and found in a day (#294). This one
|
|
29
|
+
* IGNORES: measured against the live account (#297), `per_page=200` and `per_page=1000` both answer
|
|
30
|
+
* `HTTP 200` with no error and `result_info.per_page: 100`.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `result_info.total_count` cannot be used to notice a short answer either. The same account
|
|
33
|
+
* reports `total_count: 286` and returns 61 entries on page 1, with page 2 empty — a reader that
|
|
34
|
+
* paginated on that figure would loop over empty pages and call the result partial. The truthful
|
|
35
|
+
* signal is a page that came back FULL, which the log reader already uses and this one does not
|
|
36
|
+
* yet (anchrd/intel#330).
|
|
37
|
+
*/
|
|
38
|
+
const ModelPerPage = 100;
|
|
22
39
|
/**
|
|
23
40
|
* The gateway's log entry, read tolerantly.
|
|
24
41
|
*
|
|
@@ -184,7 +201,7 @@ export function createCloudflareApi(deps) {
|
|
|
184
201
|
},
|
|
185
202
|
async workersAiModels() {
|
|
186
203
|
const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
|
|
187
|
-
per_page:
|
|
204
|
+
per_page: String(ModelPerPage),
|
|
188
205
|
hide_experimental: "true",
|
|
189
206
|
});
|
|
190
207
|
const parsed = ModelResponse.safeParse(body);
|
|
@@ -24,9 +24,56 @@ function metadataCandidates(resourceUrl, challenge) {
|
|
|
24
24
|
function resourceMetadataChallenge(header) {
|
|
25
25
|
return header?.match(/resource_metadata="([^"]+)"/i)?.[1] ?? null;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ `issuer` is REQUIRED by RFC 8414 §2 and by OpenID Connect Discovery, and it is read here for one
|
|
29
|
+
* reason: it is the only thing that says WHO a metadata document describes. Two of the URLs below are
|
|
30
|
+
* bare-root guesses, and a bare root under a multi-tenant authorization server answers for a
|
|
31
|
+
* different tenant — a perfectly valid document about somebody else.
|
|
32
|
+
*/
|
|
27
33
|
const AuthorizationServerMetadata = z.looseObject({
|
|
34
|
+
issuer: z.string().nullish(),
|
|
28
35
|
scopes_supported: z.array(z.string()).nullish(),
|
|
29
36
|
});
|
|
37
|
+
/**
|
|
38
|
+
* Where an authorization server may publish its metadata, in the order worth asking.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ One candidate was never a decision, it was an omission — `metadataCandidates` above tries three
|
|
41
|
+
* and `withAlgorithmFallback` tries two. A server that publishes only OIDC discovery read as "names
|
|
42
|
+
* no scopes", which is #97 at a different server class and with the same silent symptom (#303).
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ The three spellings are not interchangeable, and the third is the one that is easy to miss.
|
|
45
|
+
* RFC 8414 §3.1 INSERTS the issuer's path between the well-known prefix and nothing else; OpenID
|
|
46
|
+
* Connect Discovery 1.0 APPENDS its suffix to the issuer instead. A realm-style issuer
|
|
47
|
+
* (`https://as.example/realms/x`) publishes at `…/realms/x/.well-known/openid-configuration` and at
|
|
48
|
+
* neither of the other two — which is exactly the form `client.discovery` asks for further down this
|
|
49
|
+
* file, so the same server was reachable for registration and invisible here.
|
|
50
|
+
*/
|
|
51
|
+
function authorizationServerCandidates(issuer) {
|
|
52
|
+
const url = new URL(issuer);
|
|
53
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
54
|
+
return [
|
|
55
|
+
// RFC 8414 with the path inserted. First, because it is the shape the MCP portals publish.
|
|
56
|
+
`${url.origin}/.well-known/oauth-authorization-server${path}`,
|
|
57
|
+
// The same document at the bare root, for an issuer WITH a path that publishes it there anyway.
|
|
58
|
+
`${url.origin}/.well-known/oauth-authorization-server`,
|
|
59
|
+
// OpenID Connect Discovery 1.0, appended to the issuer — the realm-style form.
|
|
60
|
+
`${url.origin}${path}/.well-known/openid-configuration`,
|
|
61
|
+
// The same document read the RFC 8414 way, and at the bare root.
|
|
62
|
+
`${url.origin}/.well-known/openid-configuration${path}`,
|
|
63
|
+
`${url.origin}/.well-known/openid-configuration`,
|
|
64
|
+
].filter((value, index, values) => values.indexOf(value) === index);
|
|
65
|
+
}
|
|
66
|
+
/** Two issuer identifiers are the same server, compared the way a URL says so and not as strings. */
|
|
67
|
+
function sameIssuer(published, wanted) {
|
|
68
|
+
if (!published)
|
|
69
|
+
return false;
|
|
70
|
+
try {
|
|
71
|
+
return new URL(published).href.replace(/\/$/, "") === new URL(wanted).href.replace(/\/$/, "");
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
30
77
|
/**
|
|
31
78
|
* The scopes this authorization server says it understands, or `null` when it names none.
|
|
32
79
|
*
|
|
@@ -41,16 +88,29 @@ const AuthorizationServerMetadata = z.looseObject({
|
|
|
41
88
|
* Intel then asked it for `offline_access`, a scope it never published (#97).
|
|
42
89
|
*/
|
|
43
90
|
async function publishedScopes(fetcher, issuer) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
91
|
+
for (const candidate of authorizationServerCandidates(issuer)) {
|
|
92
|
+
const response = await fetcher(candidate, {
|
|
93
|
+
signal: AbortSignal.timeout(RequestTimeoutMs),
|
|
94
|
+
headers: { accept: "application/json" },
|
|
95
|
+
}).catch(() => null);
|
|
96
|
+
if (!response?.ok)
|
|
97
|
+
continue;
|
|
98
|
+
const metadata = AuthorizationServerMetadata.safeParse(await response.json().catch(() => null));
|
|
99
|
+
if (!metadata.success)
|
|
100
|
+
continue;
|
|
101
|
+
// ⚠️ The document has to be ABOUT this issuer, and that check is what makes the bare-root
|
|
102
|
+
// candidates safe to ask at all. Under a multi-tenant server the root answers for a different
|
|
103
|
+
// tenant, and taking its `scopes_supported` would ask THIS server for a scope it never published
|
|
104
|
+
// — the very rule this function exists to keep, broken by the fallback added to protect it.
|
|
105
|
+
// The resource loop further down does the same thing for the same reason.
|
|
106
|
+
if (!sameIssuer(metadata.data.issuer, issuer))
|
|
107
|
+
continue;
|
|
108
|
+
// ⚠️ A document that answered ABOUT THIS SERVER and named no scopes ends the search. Reading on
|
|
109
|
+
// would let the next candidate answer a question this server has already answered — "I publish
|
|
110
|
+
// none" — and the documents of one issuer may disagree about a field RFC 8414 makes optional.
|
|
111
|
+
return metadata.data.scopes_supported ?? null;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
54
114
|
}
|
|
55
115
|
/**
|
|
56
116
|
* Of the scopes Intel would like, the ones this server published — joined, or absent.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ToolTestResult } from "@anchrd/intel-contract";
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
4
5
|
function responseWithLimit(response, maxBytes) {
|
|
5
6
|
const length = Number(response.headers.get("content-length"));
|
|
6
7
|
if (Number.isFinite(length) && length > maxBytes) {
|
|
@@ -28,7 +29,26 @@ function responseWithLimit(response, maxBytes) {
|
|
|
28
29
|
}
|
|
29
30
|
export function createRemoteTools(deps) {
|
|
30
31
|
async function connected(url, accessToken, signal, maxBytes, work) {
|
|
31
|
-
|
|
32
|
+
/**
|
|
33
|
+
* ⚠️ The SDK's DEFAULT validator is Ajv, and Ajv cannot run in a Worker.
|
|
34
|
+
*
|
|
35
|
+
* `Client.cacheToolMetadata` compiles a validator for every tool that carries an `outputSchema`,
|
|
36
|
+
* Ajv builds that with `new Function`, and `workerd` answers `EvalError: Code generation from
|
|
37
|
+
* strings disallowed for this context`. The throw takes the WHOLE `tools/list` with it, so a
|
|
38
|
+
* single such tool empties the catalog of every other server (#308).
|
|
39
|
+
*
|
|
40
|
+
* Nothing announces this. Tools without an `outputSchema` never reach the line, so a portal can
|
|
41
|
+
* work for months and break the moment somebody adds a server whose tools declare one — which
|
|
42
|
+
* is exactly how it was found: three servers fine, the fourth took the screen down. And no test
|
|
43
|
+
* can catch it either: `@cloudflare/vitest-pool-workers` replaces `globalThis.Function` with a
|
|
44
|
+
* proxy onto an unsafe-eval binding, so code generation WORKS in every test and fails in every
|
|
45
|
+
* deployment.
|
|
46
|
+
*
|
|
47
|
+
* This is #229 one layer down — there Ajv sat in Intel's own schema adapter, here it sits in a
|
|
48
|
+
* dependency. The SDK offers the way out itself: `CfWorkerJsonSchemaValidator` interprets the
|
|
49
|
+
* schema instead of compiling it, and it is the same `@cfworker/json-schema` #229 settled on.
|
|
50
|
+
*/
|
|
51
|
+
const client = new Client({ name: "intel-tools", version: "0.1.0" }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
|
|
32
52
|
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
33
53
|
fetch: async (input, init) => responseWithLimit(await deps.fetch(input, {
|
|
34
54
|
...init,
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AgentDefinition, AgentMediaType, BlockNoteMediaType, BoardDocument, BoardMediaType, BundleImportResult, BundleManifest, BundleManifestFilename, DocumentLinkInlineType, FlowGraph, TableMediaType, } from "@anchrd/intel-contract";
|
|
2
2
|
import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
|
|
3
|
+
import { repeatedBoardId, upgradeStoredBoard } from "../nodes/board/board.js";
|
|
3
4
|
import { documentLinkTargets } from "../nodes/document-links/document-links.js";
|
|
4
5
|
import { parseCsv } from "../shared/csv/csv.js";
|
|
5
6
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
@@ -935,11 +936,34 @@ export function createBundle(deps) {
|
|
|
935
936
|
if (text.trim().length > 0) {
|
|
936
937
|
let document;
|
|
937
938
|
try {
|
|
938
|
-
|
|
939
|
+
// Same upgrade the stored read does (anchrd/intel#311): a bundle exported before
|
|
940
|
+
// `terminal` existed is exactly an old board in a file, and importing it must not be
|
|
941
|
+
// the one way to lose a board.
|
|
942
|
+
document = BoardDocument.parse(upgradeStoredBoard(JSON.parse(text)));
|
|
939
943
|
}
|
|
940
944
|
catch {
|
|
941
945
|
throw new IntelError(400, "import_invalid_board", `Bundle entry is not a board: ${entry.path}`);
|
|
942
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* ⚠️ Refused by name, and refused rather than folded (anchrd/intel#321).
|
|
949
|
+
*
|
|
950
|
+
* A board arriving in a file is the ONE place a document somebody else wrote becomes
|
|
951
|
+
* truth here — everywhere else a task id is minted by `deps.id()` and a status list
|
|
952
|
+
* goes through `ConfigureBoardInput`, which has demanded distinct ids since #285. Two
|
|
953
|
+
* tasks under one id threw `graph.addNode` out of the board's graph view, and two
|
|
954
|
+
* statuses under one id drew the same cards in two lanes with a drop that had two
|
|
955
|
+
* targets meaning one.
|
|
956
|
+
*
|
|
957
|
+
* Outside the catch above on purpose: swallowed into `import_invalid_board` this would
|
|
958
|
+
* read as "that file is not a board", and whoever is holding the file would look for
|
|
959
|
+
* the wrong thing. `repeatedBoardId` names the id, which is the line they have to fix.
|
|
960
|
+
*/
|
|
961
|
+
const repeated = repeatedBoardId(document);
|
|
962
|
+
if (repeated !== null) {
|
|
963
|
+
throw new IntelError(400, "import_duplicate_board_id", repeated.list === "tasks"
|
|
964
|
+
? `Bundle entry names the task “${repeated.id}” twice: ${entry.path}`
|
|
965
|
+
: `Bundle entry names the status “${repeated.id}” twice: ${entry.path}`);
|
|
966
|
+
}
|
|
943
967
|
// ⚠️ `references` are remapped the way a document's inline links are, and `dependsOn`
|
|
944
968
|
// and `parentId` are NOT (#285). A task's references point at NODES, which the import
|
|
945
969
|
// renumbers; its dependencies and its parent point at tasks inside this very file,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
|
|
2
|
+
import { upgradeStoredBoard } from "../nodes/board/board.js";
|
|
2
3
|
export class PermanentIndexingError extends Error {
|
|
3
4
|
}
|
|
4
5
|
function indexText(mediaType, content) {
|
|
@@ -33,7 +34,17 @@ function boardChunks(content) {
|
|
|
33
34
|
catch (error) {
|
|
34
35
|
throw new PermanentIndexingError(`Node version content is not valid JSON: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
35
36
|
}
|
|
36
|
-
|
|
37
|
+
/**
|
|
38
|
+
* ⚠️ Upgraded first, exactly like `parseStoredBoard` — this is the SECOND place a stored board
|
|
39
|
+
* body is parsed, and it reads bodies nobody has rewritten since (anchrd/intel#318).
|
|
40
|
+
*
|
|
41
|
+
* `reindex` walks every current version in the installation, so an old board arrives here as it
|
|
42
|
+
* was written: without `terminal` (#311) or with a repeated `dependsOn`. Without the upgrade the
|
|
43
|
+
* refusal below is PERMANENT — the version is marked failed and never retried — so that board
|
|
44
|
+
* would simply stop being findable, quietly, while its screen still worked. A rule added at one
|
|
45
|
+
* parse site and not the other is how a fix looks complete and is not.
|
|
46
|
+
*/
|
|
47
|
+
const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
|
|
37
48
|
if (!board.success) {
|
|
38
49
|
throw new PermanentIndexingError("Node version content is not a valid board document");
|
|
39
50
|
}
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -540,7 +540,7 @@ export async function handleMcp(request, deps) {
|
|
|
540
540
|
*/
|
|
541
541
|
server.registerTool("board_configure", {
|
|
542
542
|
title: "Configure board statuses",
|
|
543
|
-
description: 'Write the board\'s whole status list, in the order it should be drawn. Adding, renaming and reordering columns are all this call. The "archived" status is part of every board and cannot be removed, and a status that tasks still sit in cannot be removed either.',
|
|
543
|
+
description: 'Write the board\'s whole status list, in the order it should be drawn. Adding, renaming and reordering columns are all this call. The "archived" status is part of every board and cannot be removed, and a status that tasks still sit in cannot be removed either. Set "terminal" on a column to say that standing in it means the work is finished — that is what decides whether a task waiting on another is still blocked. Several columns may be terminal; "archived" always is and cannot be set otherwise. Omitting it leaves an ordinary column not terminal.',
|
|
544
544
|
inputSchema: ConfigureBoardInput,
|
|
545
545
|
annotations: {
|
|
546
546
|
title: "Configure board statuses",
|
|
@@ -553,7 +553,7 @@ export async function handleMcp(request, deps) {
|
|
|
553
553
|
}, async (input) => text(await deps.nodes.configureBoard(actor, input)));
|
|
554
554
|
server.registerTool("board_task_add", {
|
|
555
555
|
title: "Add board task",
|
|
556
|
-
description: "Add one task to a board. The server assigns its id and its place in the order; name afterTaskId or beforeTaskId to put it between two cards, or neither to put it last in its column. parentId makes it a subtask of another task on the same board, dependsOn names tasks of the same board it waits for, and references names Intel nodes. Needs no baseVersionId: tasks are addressed by id, so parallel writers do not collide.",
|
|
556
|
+
description: "Add one task to a board. The server assigns its id and its place in the order; name afterTaskId or beforeTaskId to put it between two cards, or neither to put it last in its column. parentId makes it a subtask of another task on the same board, dependsOn names tasks of the same board it waits for, and references names Intel nodes. dependsOn, labels and references are sets: naming the same task, word or node twice is refused rather than quietly folded together. Needs no baseVersionId: tasks are addressed by id, so parallel writers do not collide.",
|
|
557
557
|
inputSchema: AddBoardTaskInput,
|
|
558
558
|
annotations: {
|
|
559
559
|
title: "Add board task",
|
|
@@ -565,7 +565,7 @@ export async function handleMcp(request, deps) {
|
|
|
565
565
|
}, async (input) => text(await deps.nodes.addBoardTask(actor, input)));
|
|
566
566
|
server.registerTool("board_task_update", {
|
|
567
567
|
title: "Update board task",
|
|
568
|
-
description: "Change what a task says: title, assignee, labels, dates, dependencies, description or references. Where a task SITS — its status, its parent, its order — is board_task_move instead. Every named field replaces its current value whole; fields that are not named stay as they are.",
|
|
568
|
+
description: "Change what a task says: title, assignee, labels, dates, dependencies, description or references. Where a task SITS — its status, its parent, its order — is board_task_move instead. Every named field replaces its current value whole; fields that are not named stay as they are. dependsOn, labels and references are sets: appending to a list this task already holds is refused rather than quietly folded together, so read the current value before you write it back.",
|
|
569
569
|
inputSchema: UpdateBoardTaskInput,
|
|
570
570
|
annotations: {
|
|
571
571
|
title: "Update board task",
|
|
@@ -1,4 +1,48 @@
|
|
|
1
|
+
import { type BoardDocument } from "@anchrd/intel-contract";
|
|
1
2
|
import type { BoardDeps, BoardOperations } from "./board.types.js";
|
|
3
|
+
/**
|
|
4
|
+
* A stored board brought up to the current schema, before it is validated.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
|
|
7
|
+
* a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
|
|
8
|
+
* (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
|
|
9
|
+
*
|
|
10
|
+
* The upgrade is persisted by the next write of any kind, because every write serialises the whole
|
|
11
|
+
* document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
|
|
12
|
+
*/
|
|
13
|
+
export declare function upgradeStoredBoard(parsed: unknown): unknown;
|
|
14
|
+
/**
|
|
15
|
+
* The first id a board document uses for two different things (anchrd/intel#321).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
|
|
18
|
+
* reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
|
|
19
|
+
* board imported before this existed would stop answering with `board_unreadable`: the whole board
|
|
20
|
+
* gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
|
|
21
|
+
* from the other side.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
|
|
24
|
+
* repeated `dependsOn`, label or reference carries no information, so dropping the second mention
|
|
25
|
+
* loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
|
|
26
|
+
* fold on the READ is written back by the next save of any kind, because every write serialises the
|
|
27
|
+
* whole document. That is one task gone for good, for every board, without anybody having asked.
|
|
28
|
+
* The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
|
|
29
|
+
* both, and repairing the pair brings the second card back.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
|
|
32
|
+
* first `board_task_update` against such a pair writes the SAME task into both entries. The stored
|
|
33
|
+
* board is then two identical tasks rather than two different ones. That is a consequence of
|
|
34
|
+
* addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
|
|
35
|
+
* import is to stop the pair from existing, not to make it survivable.
|
|
36
|
+
*
|
|
37
|
+
* So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
|
|
38
|
+
* document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
|
|
39
|
+
* and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
|
|
40
|
+
* #285.
|
|
41
|
+
*/
|
|
42
|
+
export declare function repeatedBoardId(document: BoardDocument): {
|
|
43
|
+
list: "statuses" | "tasks";
|
|
44
|
+
id: string;
|
|
45
|
+
} | null;
|
|
2
46
|
/**
|
|
3
47
|
* Every board operation, applied to the document rather than to the file (#285).
|
|
4
48
|
*
|
|
@@ -1,6 +1,170 @@
|
|
|
1
1
|
import { ArchivedBoardStatusId, BoardDefaultStatuses, BoardMaxTaskDepth, } from "@anchrd/intel-contract";
|
|
2
2
|
import { generateKeyBetween } from "fractional-indexing";
|
|
3
3
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
4
|
+
/**
|
|
5
|
+
* Statuses that answer whether standing in them means finished (anchrd/intel#311).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ This is the ONE place the old positional rule survives, and it survives as a data migration
|
|
8
|
+
* rather than as a reading. A board written before `terminal` existed has no answer to "which
|
|
9
|
+
* columns mean finished", and the honest answer is the one that board already behaved as: the last
|
|
10
|
+
* column before `archived`, plus the shelf. Filling `false` everywhere instead would quietly declare
|
|
11
|
+
* that nothing on any existing board is done — every finished task would become an open blocker
|
|
12
|
+
* overnight. Filling `true` everywhere would be the same lie the other way round.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ It touches only what is missing. A status that already carries the flag keeps it, whatever the
|
|
15
|
+
* position rule would have said — otherwise the migration would undo somebody's configuration every
|
|
16
|
+
* time their board was read.
|
|
17
|
+
*
|
|
18
|
+
* `undefined` means "nothing to do", which is what keeps an untouched document identical rather
|
|
19
|
+
* than merely equal.
|
|
20
|
+
*/
|
|
21
|
+
function upgradedStatuses(statuses) {
|
|
22
|
+
if (!Array.isArray(statuses))
|
|
23
|
+
return undefined;
|
|
24
|
+
const entries = statuses.filter((status) => typeof status === "object" && status !== null);
|
|
25
|
+
if (entries.every((status) => "terminal" in status))
|
|
26
|
+
return undefined;
|
|
27
|
+
// The end of the work as the board behaved before the flag: the last column that is not the shelf,
|
|
28
|
+
// read in the stored order rather than in array order, because `order` is what a surface draws by.
|
|
29
|
+
const working = entries
|
|
30
|
+
.filter((status) => status.id !== ArchivedBoardStatusId)
|
|
31
|
+
.sort((left, right) => Number(left.order ?? 0) - Number(right.order ?? 0));
|
|
32
|
+
const lastWorking = working.at(-1)?.id;
|
|
33
|
+
return statuses.map((status) => {
|
|
34
|
+
if (typeof status !== "object" || status === null || "terminal" in status)
|
|
35
|
+
return status;
|
|
36
|
+
const entry = status;
|
|
37
|
+
return {
|
|
38
|
+
...entry,
|
|
39
|
+
terminal: entry.id === ArchivedBoardStatusId || entry.id === lastWorking,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The three task lists that have to be distinct, and how a stored entry is recognised again.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ `labels` are compared TRIMMED and the two id lists are not, and that mirrors the schema
|
|
47
|
+
* exactly: `BoardTaskLabel` carries `.trim()`, `BoardTaskId` and `IntelId` do not. Comparing raw
|
|
48
|
+
* here would leave `["Bug ", "Bug"]` untouched — and zod, which trims each entry BEFORE the
|
|
49
|
+
* distinctness check runs on the array, would then see one word twice and refuse the board. That is
|
|
50
|
+
* precisely the `board_unreadable` this function exists to prevent, arrived at through the fold.
|
|
51
|
+
*/
|
|
52
|
+
const distinctTaskLists = [
|
|
53
|
+
{ field: "dependsOn", identity: (value) => value },
|
|
54
|
+
{
|
|
55
|
+
field: "labels",
|
|
56
|
+
identity: (value) => (typeof value === "string" ? value.trim() : value),
|
|
57
|
+
},
|
|
58
|
+
{ field: "references", identity: (value) => value },
|
|
59
|
+
];
|
|
60
|
+
/**
|
|
61
|
+
* Tasks whose lists name nothing twice (anchrd/intel#318).
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ `dependsOn`, `labels` and `references` are refused at the write boundary now, and that refusal
|
|
64
|
+
* reaches nothing already in R2 — boards have been writable since `c29fd12` and `board_task_update`
|
|
65
|
+
* took a repeat without complaint. A stored board carrying one would meet the strict `BoardTask` and
|
|
66
|
+
* answer `board_unreadable`: the whole board lost over a value that has no reading, which is the
|
|
67
|
+
* trap #311 walked into from the other side.
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ Folded here and refused there, and the difference is not inconsistency — it is who is being
|
|
70
|
+
* answered. A caller handed back a shorter list than it sent learns nothing and repeats itself; a
|
|
71
|
+
* stored document has no caller to tell, and dropping the board instead would punish a reader for
|
|
72
|
+
* what some writer did months ago.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ The FIRST mention keeps its place. Every surface draws these lists in the order they stand in.
|
|
75
|
+
*/
|
|
76
|
+
function dedupedTaskLists(tasks) {
|
|
77
|
+
if (!Array.isArray(tasks))
|
|
78
|
+
return undefined;
|
|
79
|
+
let folded = false;
|
|
80
|
+
const next = tasks.map((task) => {
|
|
81
|
+
if (typeof task !== "object" || task === null)
|
|
82
|
+
return task;
|
|
83
|
+
const entry = task;
|
|
84
|
+
const lists = distinctTaskLists.flatMap(({ field, identity }) => {
|
|
85
|
+
const list = entry[field];
|
|
86
|
+
if (!Array.isArray(list))
|
|
87
|
+
return [];
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const distinct = list.filter((value) => {
|
|
90
|
+
const key = identity(value);
|
|
91
|
+
if (seen.has(key))
|
|
92
|
+
return false;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
return distinct.length === list.length ? [] : [[field, distinct]];
|
|
97
|
+
});
|
|
98
|
+
if (lists.length === 0)
|
|
99
|
+
return task;
|
|
100
|
+
folded = true;
|
|
101
|
+
return { ...entry, ...Object.fromEntries(lists) };
|
|
102
|
+
});
|
|
103
|
+
return folded ? next : undefined;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A stored board brought up to the current schema, before it is validated.
|
|
107
|
+
*
|
|
108
|
+
* ⚠️ It runs before `BoardDocument.parse`, on `unknown`, because after that parse a missing field or
|
|
109
|
+
* a repeated id is already a refusal — and `parseStoredBoard` is deliberately loud
|
|
110
|
+
* (`board_unreadable`), so an un-upgraded board would not read as "old", it would read as corrupt.
|
|
111
|
+
*
|
|
112
|
+
* The upgrade is persisted by the next write of any kind, because every write serialises the whole
|
|
113
|
+
* document. Nothing has to be migrated ahead of time and nothing has to be re-migrated.
|
|
114
|
+
*/
|
|
115
|
+
export function upgradeStoredBoard(parsed) {
|
|
116
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
117
|
+
return parsed;
|
|
118
|
+
const document = parsed;
|
|
119
|
+
const statuses = upgradedStatuses(document.statuses);
|
|
120
|
+
const tasks = dedupedTaskLists(document.tasks);
|
|
121
|
+
if (statuses === undefined && tasks === undefined)
|
|
122
|
+
return parsed;
|
|
123
|
+
return {
|
|
124
|
+
...document,
|
|
125
|
+
...(statuses !== undefined && { statuses }),
|
|
126
|
+
...(tasks !== undefined && { tasks }),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The first id a board document uses for two different things (anchrd/intel#321).
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ Deliberately NOT a rule on `BoardDocument`, and that is the whole decision. A schema rule would
|
|
133
|
+
* reach every parse of a stored body — `parseStoredBoard`, `indexing.ts`, the link reader — and a
|
|
134
|
+
* board imported before this existed would stop answering with `board_unreadable`: the whole board
|
|
135
|
+
* gone over something that costs one view. It is the trap #311 walked into, and #318 walked into
|
|
136
|
+
* from the other side.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ And NOT folded in `upgradeStoredBoard` either, which is where the sibling rules of #318 went. A
|
|
139
|
+
* repeated `dependsOn`, label or reference carries no information, so dropping the second mention
|
|
140
|
+
* loses nothing. Two tasks under one id are two whole tasks — titles, dates, descriptions — and a
|
|
141
|
+
* fold on the READ is written back by the next save of any kind, because every write serialises the
|
|
142
|
+
* whole document. That is one task gone for good, for every board, without anybody having asked.
|
|
143
|
+
* The UI folds the same pair for a DRAWING (`orderedTasks`), which costs nothing: the document keeps
|
|
144
|
+
* both, and repairing the pair brings the second card back.
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ What this does NOT rescue, and it is worth knowing: `replaced` matches a task by id, so the
|
|
147
|
+
* first `board_task_update` against such a pair writes the SAME task into both entries. The stored
|
|
148
|
+
* board is then two identical tasks rather than two different ones. That is a consequence of
|
|
149
|
+
* addressing a board by task id at all (#285) and predates this rule; the reason the rule is at the
|
|
150
|
+
* import is to stop the pair from existing, not to make it survivable.
|
|
151
|
+
*
|
|
152
|
+
* So it is asked exactly where there is a caller to answer: the bundle import, the one door a board
|
|
153
|
+
* document written elsewhere comes in through. Everything else mints task ids itself (`deps.id()`)
|
|
154
|
+
* and takes its status list through `ConfigureBoardInput`, which has demanded distinct ids since
|
|
155
|
+
* #285.
|
|
156
|
+
*/
|
|
157
|
+
export function repeatedBoardId(document) {
|
|
158
|
+
for (const list of ["statuses", "tasks"]) {
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
for (const entry of document[list]) {
|
|
161
|
+
if (seen.has(entry.id))
|
|
162
|
+
return { list, id: entry.id };
|
|
163
|
+
seen.add(entry.id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
4
168
|
/**
|
|
5
169
|
* Every board operation, applied to the document rather than to the file (#285).
|
|
6
170
|
*
|
|
@@ -260,6 +424,11 @@ export function createBoard(deps) {
|
|
|
260
424
|
id: status.id,
|
|
261
425
|
label: status.label,
|
|
262
426
|
order: index,
|
|
427
|
+
// ⚠️ The shelf is always terminal and the boundary refuses an explicit `false` for it, so
|
|
428
|
+
// this is not a silent correction of something a caller asked for — it is the same fact
|
|
429
|
+
// stated where the document is built. Everything else defaults to "not finished": a new
|
|
430
|
+
// column is work, and a column that ends work is a thing somebody says on purpose (#311).
|
|
431
|
+
terminal: status.id === ArchivedBoardStatusId ? true : (status.terminal ?? false),
|
|
263
432
|
}));
|
|
264
433
|
return { statuses: next, tasks: board.tasks };
|
|
265
434
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BlockNoteDocument, BlockNoteMediaType, BoardDocument, BoardMediaType, DocumentLinkInlineType, } from "@anchrd/intel-contract";
|
|
2
|
+
import { upgradeStoredBoard } from "../board/board.js";
|
|
2
3
|
function isRecord(value) {
|
|
3
4
|
return typeof value === "object" && value !== null;
|
|
4
5
|
}
|
|
@@ -52,7 +53,17 @@ export function documentLinkTargets(mediaType, content) {
|
|
|
52
53
|
}
|
|
53
54
|
const found = new Set();
|
|
54
55
|
if (mediaType === BoardMediaType) {
|
|
55
|
-
|
|
56
|
+
/**
|
|
57
|
+
* ⚠️ Upgraded first, like `parseStoredBoard` and `indexing.ts` — this is the FOURTH place a
|
|
58
|
+
* board body is parsed, and the only one that cannot complain (anchrd/intel#321).
|
|
59
|
+
*
|
|
60
|
+
* A board it fails to parse simply has no links, which reads exactly like a board that points
|
|
61
|
+
* at nothing — and the reconciliation this feeds removes the links a body no longer names, so
|
|
62
|
+
* an unparsed old board would quietly leave the link graph. Every caller hands it a body written
|
|
63
|
+
* moments earlier today, so it has never met one; the upgrade is what keeps that from being the
|
|
64
|
+
* only reason.
|
|
65
|
+
*/
|
|
66
|
+
const board = BoardDocument.safeParse(upgradeStoredBoard(parsed));
|
|
56
67
|
if (!board.success)
|
|
57
68
|
return [];
|
|
58
69
|
for (const task of board.data.tasks) {
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AgentDefinition, AgentMediaType, agentReferenceAccepts, BoardDocument,
|
|
|
2
2
|
import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
|
|
3
3
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
4
4
|
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
5
|
-
import { createBoard } from "./board/board.js";
|
|
5
|
+
import { createBoard, upgradeStoredBoard } from "./board/board.js";
|
|
6
6
|
import { documentLinkTargets } from "./document-links/document-links.js";
|
|
7
7
|
// ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
|
|
8
8
|
// is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
|
|
@@ -470,7 +470,11 @@ export function createNodes(deps) {
|
|
|
470
470
|
catch {
|
|
471
471
|
throw new IntelError(500, "board_unreadable", "The stored board cannot be read");
|
|
472
472
|
}
|
|
473
|
-
|
|
473
|
+
// ⚠️ Upgraded BEFORE validation, not after. `terminal` is required now (anchrd/intel#311), and
|
|
474
|
+
// this parse is deliberately loud — so a board written before the flag existed would answer
|
|
475
|
+
// `board_unreadable` rather than "old", and every board in every installation would break on
|
|
476
|
+
// deploy. `upgradeStoredBoard` fills only what is missing; the next write persists it.
|
|
477
|
+
const document = BoardDocument.safeParse(upgradeStoredBoard(parsed));
|
|
474
478
|
// ⚠️ Loud, unlike the tolerant read the share warning makes of an agent definition. A board IS
|
|
475
479
|
// its document: answering with an empty one would show somebody a board with no tasks on it,
|
|
476
480
|
// and the next write would store that as the truth.
|
package/dist/tools/tools.js
CHANGED
|
@@ -260,7 +260,13 @@ export function createTools(deps) {
|
|
|
260
260
|
if (!stored)
|
|
261
261
|
return { portalConnected: false, items: [] };
|
|
262
262
|
try {
|
|
263
|
-
|
|
263
|
+
const { items, serverOfTool } = await capabilities(who);
|
|
264
|
+
// ⚠️ Named only for a delegated caller, and it costs nothing there: the attribution had to
|
|
265
|
+
// be made anyway to cut the list. For an ordinary user it would mean a second portal
|
|
266
|
+
// request per call — `serverList` is not on their path — and their screen never asks the
|
|
267
|
+
// question. The runtime's is the one that does (#289).
|
|
268
|
+
const reached = who.delegation ? [...new Set(serverOfTool.values())].sort() : undefined;
|
|
269
|
+
return { portalConnected: true, items, ...(reached ? { reached } : {}) };
|
|
264
270
|
}
|
|
265
271
|
catch (error) {
|
|
266
272
|
if (disconnected(error))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.11.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|