@anchrd/intel-api 0.12.1 → 0.12.3
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/db/db-indexing.js +18 -7
- package/dist/adapters/db/db.js +61 -7
- package/dist/adapters/openid/openid.js +51 -7
- package/dist/auth/auth.js +11 -3
- package/dist/auth/auth.types.d.ts +1 -0
- package/dist/bundle/bundle.js +57 -2
- package/dist/http/http.js +49 -1
- package/dist/indexing/indexing.js +38 -3
- package/dist/mcp/mcp.js +89 -1
- package/dist/nodes/board/board.d.ts +15 -0
- package/dist/nodes/board/board.js +359 -0
- package/dist/nodes/board/board.types.d.ts +31 -0
- package/dist/nodes/board/board.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +9 -2
- package/dist/nodes/document-links/document-links.js +22 -5
- package/dist/nodes/nodes.js +308 -2
- package/dist/nodes/nodes.types.d.ts +53 -9
- package/migrations/0016_boards_in_the_tree.sql +80 -0
- package/package.json +3 -2
|
@@ -40,7 +40,18 @@ export function createNodeIndexRepository(db) {
|
|
|
40
40
|
.all();
|
|
41
41
|
return mapTarget(row, (segments.results ?? []).map((segment) => segment.content_key));
|
|
42
42
|
},
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Every passage of this node, replacing every passage it had.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ One row per chunk, in ONE batch with the delete in front of it. Most kinds bring exactly
|
|
47
|
+
* one chunk and this is the statement pair it always was; a board brings one per task (#285),
|
|
48
|
+
* and a search can then answer with the card that matched rather than with the whole board.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ A board with no tasks writes no row at all, and that is right: there is nothing to find in
|
|
51
|
+
* it. What must not happen is the delete landing without the inserts, which is why they share a
|
|
52
|
+
* batch — D1 runs it as one transaction.
|
|
53
|
+
*/
|
|
54
|
+
async replace(target, chunks) {
|
|
44
55
|
await db.batch([
|
|
45
56
|
db
|
|
46
57
|
.prepare(`DELETE FROM node_fts WHERE node_id = ? AND EXISTS (
|
|
@@ -48,13 +59,13 @@ export function createNodeIndexRepository(db) {
|
|
|
48
59
|
WHERE id = ? AND current_version_id = ? AND archived_at IS NULL
|
|
49
60
|
)`)
|
|
50
61
|
.bind(target.nodeId, target.nodeId, target.versionId),
|
|
51
|
-
db
|
|
62
|
+
...chunks.map((chunk) => db
|
|
52
63
|
.prepare(`INSERT INTO node_fts (version_id, node_id, title, content)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.bind(target.versionId, target.nodeId,
|
|
64
|
+
SELECT ?, ?, ?, ? WHERE EXISTS (
|
|
65
|
+
SELECT 1 FROM nodes
|
|
66
|
+
WHERE id = ? AND current_version_id = ? AND archived_at IS NULL
|
|
67
|
+
)`)
|
|
68
|
+
.bind(target.versionId, target.nodeId, chunk.title, chunk.text, target.nodeId, target.versionId)),
|
|
58
69
|
]);
|
|
59
70
|
},
|
|
60
71
|
async markIndexed(versionId, occurredAt) {
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -5,6 +5,18 @@ const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_a
|
|
|
5
5
|
created_by, created_at`;
|
|
6
6
|
const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
|
|
7
7
|
link.origin, link.label, link.created_by, link.created_at`;
|
|
8
|
+
// The first row of each node, in the order they arrive. A chunked kind — a board, whose index holds
|
|
9
|
+
// one row per task (#285) — answers a query once per matching card; a citation names a node, so the
|
|
10
|
+
// best of them is the one that gets to speak for it.
|
|
11
|
+
function dedupedByNode(rows) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return rows.filter((row) => {
|
|
14
|
+
if (seen.has(row.node_id))
|
|
15
|
+
return false;
|
|
16
|
+
seen.add(row.node_id);
|
|
17
|
+
return true;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
8
20
|
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
9
21
|
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
|
|
10
22
|
function mapNode(row) {
|
|
@@ -363,6 +375,32 @@ export function createNodeRepository(deps) {
|
|
|
363
375
|
throw error;
|
|
364
376
|
}
|
|
365
377
|
},
|
|
378
|
+
/**
|
|
379
|
+
* What an earlier write with this key recorded about itself (#285).
|
|
380
|
+
*
|
|
381
|
+
* ⚠️ The idempotency row points at the VERSION, and the audit event points at the node, so the
|
|
382
|
+
* two are joined through the version id the snapshot writer puts into the metadata. That
|
|
383
|
+
* metadata is where a board operation says which task it touched and how many it removed —
|
|
384
|
+
* facts a replay cannot recompute from the stored document, because the document is the state
|
|
385
|
+
* AFTER the write. Without this, repeating a delete would answer with a count nobody measured.
|
|
386
|
+
*/
|
|
387
|
+
async findSnapshotMetadata(actorId, operation, idempotencyKey) {
|
|
388
|
+
const row = await deps.db
|
|
389
|
+
.prepare(`SELECT audit.metadata_json AS metadata_json
|
|
390
|
+
FROM idempotency_keys keys
|
|
391
|
+
JOIN audit_events audit
|
|
392
|
+
ON audit.action = keys.operation
|
|
393
|
+
AND json_extract(audit.metadata_json, '$.versionId') = keys.resource_id
|
|
394
|
+
WHERE keys.actor_id = ? AND keys.operation = ? AND keys.idempotency_key = ?`)
|
|
395
|
+
.bind(actorId, operation, idempotencyKey)
|
|
396
|
+
.first();
|
|
397
|
+
if (!row)
|
|
398
|
+
return null;
|
|
399
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
400
|
+
return typeof parsed === "object" && parsed !== null
|
|
401
|
+
? parsed
|
|
402
|
+
: null;
|
|
403
|
+
},
|
|
366
404
|
async findImportReplay(actorId, idempotencyKey) {
|
|
367
405
|
const row = await deps.db
|
|
368
406
|
.prepare(`SELECT audit.metadata_json AS metadata_json
|
|
@@ -777,7 +815,8 @@ export function createNodeRepository(deps) {
|
|
|
777
815
|
return mapVersion(row);
|
|
778
816
|
},
|
|
779
817
|
/**
|
|
780
|
-
* One snapshot
|
|
818
|
+
* One snapshot — the whole readable state after a mutation — written only against the state it
|
|
819
|
+
* replaces (#135, #285).
|
|
781
820
|
*
|
|
782
821
|
* ⚠️ The shape of `appendVersion`, not of the append above it: the base check sits inside the
|
|
783
822
|
* INSERT's own WHERE, so a segment that landed between the service's read and this batch makes
|
|
@@ -787,17 +826,23 @@ export function createNodeRepository(deps) {
|
|
|
787
826
|
* The idempotency row carries the mutation's own operation, so a replayed update can never be
|
|
788
827
|
* confused with a delete that reused the key, and the audit trail names what was done.
|
|
789
828
|
*/
|
|
790
|
-
async
|
|
829
|
+
async appendSnapshotVersion(input) {
|
|
791
830
|
const version = input.version;
|
|
792
831
|
await deps.db.batch([
|
|
793
832
|
deps.db
|
|
794
833
|
.prepare(`INSERT INTO node_versions (
|
|
795
834
|
id, node_id, sequence, content_key, media_type, content_hash, size, segment,
|
|
796
835
|
created_by, created_at
|
|
797
|
-
) SELECT ?, ?, ?, ?, ?, ?, ?,
|
|
836
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
798
837
|
FROM nodes
|
|
799
838
|
WHERE id = ? AND current_version_id IS ?`)
|
|
800
|
-
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size,
|
|
839
|
+
.bind(version.id, version.nodeId, version.sequence, version.contentKey, version.mediaType, version.contentHash, version.size,
|
|
840
|
+
// ⚠️ Taken from the version rather than written in as `'snapshot'`. A table's mutation
|
|
841
|
+
// marks a snapshot, because reading a table starts at the newest one and everything
|
|
842
|
+
// before it is history (#135). A board's version carries the whole board by
|
|
843
|
+
// construction, so it marks nothing — the same `null` a document's version carries, and
|
|
844
|
+
// for the same reason (#285).
|
|
845
|
+
version.segment, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
|
|
801
846
|
deps.db
|
|
802
847
|
.prepare(`UPDATE nodes
|
|
803
848
|
SET current_version_id = ?, updated_at = ?
|
|
@@ -1083,7 +1128,12 @@ export function createNodeRepository(deps) {
|
|
|
1083
1128
|
LIMIT ?`)
|
|
1084
1129
|
.bind(...readBindings(actor), ...scopeBindings(input.scopeId), query, input.limit)
|
|
1085
1130
|
.all();
|
|
1086
|
-
|
|
1131
|
+
// ⚠️ One citation per node, and the FIRST is the one kept. A board holds one index row per
|
|
1132
|
+
// task (#285), so a query matching three cards of the same board arrives here three times;
|
|
1133
|
+
// the rows come back in rank order, so the survivor is the best-matching card and its own
|
|
1134
|
+
// passage is what the reader is shown. Without this, one busy board could fill a whole page
|
|
1135
|
+
// of results and push every other node off it.
|
|
1136
|
+
return dedupedByNode(result.results ?? []).map((row) => ({
|
|
1087
1137
|
nodeId: row.node_id,
|
|
1088
1138
|
versionId: row.version_id,
|
|
1089
1139
|
title: row.title,
|
|
@@ -1117,8 +1167,12 @@ export function createNodeRepository(deps) {
|
|
|
1117
1167
|
rows.push(...(result.results ?? []));
|
|
1118
1168
|
}
|
|
1119
1169
|
const order = new Map(uniqueIds.map((id, index) => [id, index]));
|
|
1120
|
-
|
|
1121
|
-
|
|
1170
|
+
// The same one-per-node rule as the lexical half above, for the same reason: a semantic hit
|
|
1171
|
+
// is a hit on a NODE (the vector index is keyed by node id), so hydrating it must not turn one
|
|
1172
|
+
// hit into one row per task.
|
|
1173
|
+
const deduped = dedupedByNode(rows);
|
|
1174
|
+
deduped.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
|
|
1175
|
+
return deduped.map((row) => ({
|
|
1122
1176
|
nodeId: row.node_id,
|
|
1123
1177
|
versionId: row.version_id,
|
|
1124
1178
|
title: row.title,
|
|
@@ -24,8 +24,45 @@ function metadataCandidates(resourceUrl, challenge) {
|
|
|
24
24
|
function resourceMetadataChallenge(header) {
|
|
25
25
|
return header?.match(/resource_metadata="([^"]+)"/i)?.[1] ?? null;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
const AuthorizationServerMetadata = z.looseObject({
|
|
28
|
+
scopes_supported: z.array(z.string()).nullish(),
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The scopes this authorization server says it understands, or `null` when it names none.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ `null` is not "no scopes" — RFC 8414 makes `scopes_supported` OPTIONAL, and Cloudflare Access
|
|
34
|
+
* omits it. It means "this server never said", and the only safe reading of that is to ask for
|
|
35
|
+
* nothing: a scope the server does not know is at best ignored and at worst turns the whole
|
|
36
|
+
* authorization down, and neither failure names the scope as its cause.
|
|
37
|
+
*
|
|
38
|
+
* This replaces a hostname check (`issuer.endsWith(".cloudflareaccess.com")`) that decided the same
|
|
39
|
+
* question by guessing who the server was. A Cloudflare Access portal on its own domain — the
|
|
40
|
+
* normal case for anyone who cares what their portal is called — was read as some other vendor, and
|
|
41
|
+
* Intel then asked it for `offline_access`, a scope it never published (#97).
|
|
42
|
+
*/
|
|
43
|
+
async function publishedScopes(fetcher, issuer) {
|
|
44
|
+
const url = new URL(issuer);
|
|
45
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
|
|
46
|
+
const response = await fetcher(`${url.origin}/.well-known/oauth-authorization-server${path}`, {
|
|
47
|
+
signal: AbortSignal.timeout(RequestTimeoutMs),
|
|
48
|
+
headers: { accept: "application/json" },
|
|
49
|
+
}).catch(() => null);
|
|
50
|
+
if (!response?.ok)
|
|
51
|
+
return null;
|
|
52
|
+
const metadata = AuthorizationServerMetadata.safeParse(await response.json().catch(() => null));
|
|
53
|
+
return metadata.success ? (metadata.data.scopes_supported ?? null) : null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Of the scopes Intel would like, the ones this server published — joined, or absent.
|
|
57
|
+
*
|
|
58
|
+
* `offline_access` is the one that matters: it is where a refresh token comes from on a server that
|
|
59
|
+
* gates it behind a scope. Asking for it unconditionally is what a hostname check amounted to.
|
|
60
|
+
*/
|
|
61
|
+
function requestableScope(published) {
|
|
62
|
+
if (!published)
|
|
63
|
+
return undefined;
|
|
64
|
+
const wanted = ["openid", "profile", "email", "offline_access"].filter((scope) => published.includes(scope));
|
|
65
|
+
return wanted.length > 0 ? wanted.join(" ") : undefined;
|
|
29
66
|
}
|
|
30
67
|
function tokenResponse(tokens) {
|
|
31
68
|
return {
|
|
@@ -122,10 +159,11 @@ export function createOpenId(deps) {
|
|
|
122
159
|
continue;
|
|
123
160
|
const issuer = metadata.data.authorization_servers[0];
|
|
124
161
|
if (issuer) {
|
|
162
|
+
const scope = requestableScope(await publishedScopes(deps.fetch, issuer));
|
|
125
163
|
return {
|
|
126
164
|
resource: metadata.data.resource,
|
|
127
165
|
issuer,
|
|
128
|
-
...(
|
|
166
|
+
...(scope ? { scope } : {}),
|
|
129
167
|
};
|
|
130
168
|
}
|
|
131
169
|
}
|
|
@@ -142,11 +180,17 @@ export function createOpenId(deps) {
|
|
|
142
180
|
// not survive its first access token (#97): `offline_access` was being requested and
|
|
143
181
|
// the right to act on it never was.
|
|
144
182
|
grant_types: ["authorization_code", "refresh_token"],
|
|
145
|
-
...(
|
|
146
|
-
? {}
|
|
147
|
-
: { scope: "openid profile email offline_access" }),
|
|
183
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
148
184
|
token_endpoint_auth_method: "none",
|
|
149
|
-
|
|
185
|
+
// ⚠️ Unconditional, and that is the protocol rather than a vendor accommodation. The
|
|
186
|
+
// MCP authorization spec: "MCP clients MUST send this parameter regardless of whether
|
|
187
|
+
// authorization servers support it" (RFC 8707 Resource Indicators). Sending it only to
|
|
188
|
+
// hosts whose name ended in `.cloudflareaccess.com` meant every portal on its own
|
|
189
|
+
// domain registered as a client that never named the resource it wanted a token for —
|
|
190
|
+
// and Cloudflare's Managed OAuth requires exactly that conformance before it will hand
|
|
191
|
+
// out a refresh token. The connection then died at the first token expiry, and the log
|
|
192
|
+
// said "no refresh token was ever issued" (#97).
|
|
193
|
+
resource: input.resource,
|
|
150
194
|
}, client.None(), { ...options(input.issuer), ...(algorithm ? { algorithm } : {}) }));
|
|
151
195
|
return configuration.clientMetadata().client_id;
|
|
152
196
|
},
|
package/dist/auth/auth.js
CHANGED
|
@@ -60,8 +60,15 @@ export function createBrowserAuth(deps) {
|
|
|
60
60
|
const gateIssuer = deps.gateUrl.replace(/\/+$/, "");
|
|
61
61
|
const redirectUri = `${baseUrl}/auth/callback`;
|
|
62
62
|
const secure = new URL(baseUrl).protocol === "https:";
|
|
63
|
-
async function clientId(issuer, resource) {
|
|
64
|
-
|
|
63
|
+
async function clientId(issuer, resource, scope) {
|
|
64
|
+
// ⚠️ The suffix is a registration generation, and it is what makes a fix to the registration
|
|
65
|
+
// reach an installation that already has one. A stored client is reused forever, so #97 —
|
|
66
|
+
// clients registered without the `resource` parameter and therefore never given a refresh
|
|
67
|
+
// token — would have been repaired in code and nowhere else: every existing deployment would
|
|
68
|
+
// keep handing back the broken registration it made months ago. Raise this whenever the
|
|
69
|
+
// REGISTRATION changes shape, never for anything else; each raise costs one silent
|
|
70
|
+
// re-registration per issuer and resource.
|
|
71
|
+
const registrationKey = `${issuer}#${resource}#r2`;
|
|
65
72
|
const stored = await deps.clients.get(registrationKey, redirectUri);
|
|
66
73
|
if (stored)
|
|
67
74
|
return stored;
|
|
@@ -74,6 +81,7 @@ export function createBrowserAuth(deps) {
|
|
|
74
81
|
redirectUri,
|
|
75
82
|
clientName: "Intel",
|
|
76
83
|
resource,
|
|
84
|
+
...(scope ? { scope } : {}),
|
|
77
85
|
})
|
|
78
86
|
.catch((error) => {
|
|
79
87
|
reportUnexpectedError(error);
|
|
@@ -148,7 +156,7 @@ export function createBrowserAuth(deps) {
|
|
|
148
156
|
const discovered = await deps.oauth.discoverResource(deps.portalUrl).catch(() => {
|
|
149
157
|
throw new IntelError(409, "portal_oauth_unavailable", "The MCP portal does not publish usable OAuth metadata");
|
|
150
158
|
});
|
|
151
|
-
const connectionClientId = await clientId(discovered.issuer, discovered.resource);
|
|
159
|
+
const connectionClientId = await clientId(discovered.issuer, discovered.resource, discovered.scope);
|
|
152
160
|
const { verifier: codeVerifier, challenge: codeChallenge } = await pkce();
|
|
153
161
|
const state = randomState();
|
|
154
162
|
const pending = {
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AgentDefinition, AgentMediaType, BlockNoteMediaType, BundleImportResult, BundleManifest, BundleManifestFilename, DocumentLinkInlineType, FlowGraph, TableMediaType, } from "@anchrd/intel-contract";
|
|
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
3
|
import { documentLinkTargets } from "../nodes/document-links/document-links.js";
|
|
4
4
|
import { parseCsv } from "../shared/csv/csv.js";
|
|
@@ -216,6 +216,13 @@ function nakedKindOf(name) {
|
|
|
216
216
|
if (lower.endsWith(".csv")) {
|
|
217
217
|
return { kind: "table", mediaType: TableMediaType, title: name.slice(0, -4) };
|
|
218
218
|
}
|
|
219
|
+
// ⚠️ Before any plain `.json` rule and with a compound suffix on purpose (#285). A board is not
|
|
220
|
+
// recognisable from `.json` alone — an agent definition wears the same extension — so the export
|
|
221
|
+
// writes `.board.json` and a naked upload has to spell it out too. A file that only says `.json`
|
|
222
|
+
// is an attachment, which is what it was before boards existed.
|
|
223
|
+
if (lower.endsWith(".board.json")) {
|
|
224
|
+
return { kind: "board", mediaType: BoardMediaType, title: name.slice(0, -11) };
|
|
225
|
+
}
|
|
219
226
|
return { kind: "attachment", mediaType: guessedMediaType(lower), title: name };
|
|
220
227
|
}
|
|
221
228
|
const KnownMediaTypes = {
|
|
@@ -394,6 +401,25 @@ export function createBundle(deps) {
|
|
|
394
401
|
},
|
|
395
402
|
};
|
|
396
403
|
}
|
|
404
|
+
// A board is one JSON file, exactly like an agent's definition: the document IS the board, so
|
|
405
|
+
// there is nothing to serialize differently for an export (#285). A board with no version yet
|
|
406
|
+
// exports as an empty file, the same way an agent without a definition does.
|
|
407
|
+
if (node.kind === "board") {
|
|
408
|
+
const path = `${directory}${uniqueName(used, base, ".board.json")}`;
|
|
409
|
+
return {
|
|
410
|
+
manifest: {
|
|
411
|
+
id: node.id,
|
|
412
|
+
kind: "board",
|
|
413
|
+
title: node.title,
|
|
414
|
+
description: node.description,
|
|
415
|
+
mediaType: version?.mediaType ?? BoardMediaType,
|
|
416
|
+
path,
|
|
417
|
+
},
|
|
418
|
+
content: version === null
|
|
419
|
+
? { type: "text", load: async () => "" }
|
|
420
|
+
: { type: "text", load: textLoader(version.contentKey) },
|
|
421
|
+
};
|
|
422
|
+
}
|
|
397
423
|
if (node.kind === "agent") {
|
|
398
424
|
const path = `${directory}${uniqueName(used, base, ".json")}`;
|
|
399
425
|
return {
|
|
@@ -904,6 +930,35 @@ export function createBundle(deps) {
|
|
|
904
930
|
version = versionRowFor(entry, text, TableMediaType, await deps.hash(text), "snapshot");
|
|
905
931
|
}
|
|
906
932
|
}
|
|
933
|
+
else if (entry.kind === "board") {
|
|
934
|
+
const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
|
|
935
|
+
if (text.trim().length > 0) {
|
|
936
|
+
let document;
|
|
937
|
+
try {
|
|
938
|
+
document = BoardDocument.parse(JSON.parse(text));
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
throw new IntelError(400, "import_invalid_board", `Bundle entry is not a board: ${entry.path}`);
|
|
942
|
+
}
|
|
943
|
+
// ⚠️ `references` are remapped the way a document's inline links are, and `dependsOn`
|
|
944
|
+
// and `parentId` are NOT (#285). A task's references point at NODES, which the import
|
|
945
|
+
// renumbers; its dependencies and its parent point at tasks inside this very file,
|
|
946
|
+
// whose ids the import does not touch. Renumbering those would break every edge in the
|
|
947
|
+
// board for no reason.
|
|
948
|
+
const remapped = {
|
|
949
|
+
statuses: document.statuses,
|
|
950
|
+
tasks: document.tasks.map((task) => ({
|
|
951
|
+
...task,
|
|
952
|
+
references: task.references.map((reference) => idMap.get(reference) ?? reference),
|
|
953
|
+
})),
|
|
954
|
+
};
|
|
955
|
+
const body = JSON.stringify(remapped);
|
|
956
|
+
version = versionRowFor(entry, body, BoardMediaType, await deps.hash(body), null);
|
|
957
|
+
// Into the same link pass as an imported document: a board's references are `text`
|
|
958
|
+
// links, so they have to survive the import the same way.
|
|
959
|
+
documentBodies.set(entry.newId, { mediaType: BoardMediaType, content: body });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
907
962
|
else if (entry.kind === "agent") {
|
|
908
963
|
const text = entry.body === null ? "" : decodeText(entry.body, entry.path);
|
|
909
964
|
if (text.trim().length > 0) {
|
|
@@ -952,7 +1007,7 @@ export function createBundle(deps) {
|
|
|
952
1007
|
nodes.push({
|
|
953
1008
|
id: entry.newId,
|
|
954
1009
|
parentId,
|
|
955
|
-
// Flows took the `continue` above; what reaches here is one of the
|
|
1010
|
+
// Flows took the `continue` above; what reaches here is one of the six node kinds.
|
|
956
1011
|
kind: entry.kind,
|
|
957
1012
|
// A manifest title and a file name are both somebody else's text, and an export written
|
|
958
1013
|
// by an escaping chain carries the entity in both (#202).
|
package/dist/http/http.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListNodesInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AddBoardTaskInput, AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, ConfigureBoardInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteTableRowsInput, ExecuteToolInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListNodesInput, MoveBoardTaskInput, NodeGraphInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateBoardTaskInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
|
|
@@ -438,6 +438,54 @@ export function createHttp(deps) {
|
|
|
438
438
|
}
|
|
439
439
|
return context.json(await deps.nodes.redefineTable(asActor(auth), input), 201);
|
|
440
440
|
});
|
|
441
|
+
// The board (#285). One read and five task operations, all action-style POSTs for the same reason
|
|
442
|
+
// the table mutations are: what they address is a task ID in the body, and none of them has an
|
|
443
|
+
// address of its own to PUT to. Every one writes a version, so they all answer 201 like the
|
|
444
|
+
// table's do.
|
|
445
|
+
app.get("/nodes/:nodeId/board", async (context) => {
|
|
446
|
+
const auth = requireCapability(context, "knowledge", "read");
|
|
447
|
+
return context.json(await deps.nodes.getBoard(asActor(auth), { nodeId: context.req.param("nodeId") }));
|
|
448
|
+
});
|
|
449
|
+
app.post("/nodes/:nodeId/board/configure", async (context) => {
|
|
450
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
451
|
+
const input = ConfigureBoardInput.parse(await context.req.json().catch(() => null));
|
|
452
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
453
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
454
|
+
}
|
|
455
|
+
return context.json(await deps.nodes.configureBoard(asActor(auth), input), 201);
|
|
456
|
+
});
|
|
457
|
+
app.post("/nodes/:nodeId/board/tasks", async (context) => {
|
|
458
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
459
|
+
const input = AddBoardTaskInput.parse(await context.req.json().catch(() => null));
|
|
460
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
461
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
462
|
+
}
|
|
463
|
+
return context.json(await deps.nodes.addBoardTask(asActor(auth), input), 201);
|
|
464
|
+
});
|
|
465
|
+
app.post("/nodes/:nodeId/board/tasks/update", async (context) => {
|
|
466
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
467
|
+
const input = UpdateBoardTaskInput.parse(await context.req.json().catch(() => null));
|
|
468
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
469
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
470
|
+
}
|
|
471
|
+
return context.json(await deps.nodes.updateBoardTask(asActor(auth), input), 201);
|
|
472
|
+
});
|
|
473
|
+
app.post("/nodes/:nodeId/board/tasks/move", async (context) => {
|
|
474
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
475
|
+
const input = MoveBoardTaskInput.parse(await context.req.json().catch(() => null));
|
|
476
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
477
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
478
|
+
}
|
|
479
|
+
return context.json(await deps.nodes.moveBoardTask(asActor(auth), input), 201);
|
|
480
|
+
});
|
|
481
|
+
app.post("/nodes/:nodeId/board/tasks/delete", async (context) => {
|
|
482
|
+
const auth = requireCapability(context, "knowledge", "write");
|
|
483
|
+
const input = DeleteBoardTaskInput.parse(await context.req.json().catch(() => null));
|
|
484
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
485
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
486
|
+
}
|
|
487
|
+
return context.json(await deps.nodes.deleteBoardTask(asActor(auth), input), 201);
|
|
488
|
+
});
|
|
441
489
|
app.get("/nodes/:nodeId/links", async (context) => {
|
|
442
490
|
const auth = requireCapability(context, "knowledge", "read");
|
|
443
491
|
return context.json(await deps.nodes.listLinks(asActor(auth), context.req.param("nodeId")));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BlockNoteDocument, BlockNoteMediaType } from "@anchrd/intel-contract";
|
|
1
|
+
import { BlockNoteDocument, BlockNoteMediaType, BoardDocument } from "@anchrd/intel-contract";
|
|
2
2
|
export class PermanentIndexingError extends Error {
|
|
3
3
|
}
|
|
4
4
|
function indexText(mediaType, content) {
|
|
@@ -13,6 +13,35 @@ function indexText(mediaType, content) {
|
|
|
13
13
|
throw new PermanentIndexingError(`Node version content is not a valid BlockNote document: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* One passage per task, never one per board (#285).
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ This is the whole reason a board is chunked at all. "Where do I stand with X" has to land on a
|
|
20
|
+
* CARD: indexed as one blob, a board of three hundred tasks matches on any of them and answers with
|
|
21
|
+
* whichever thousand characters the snippet function happened to cut — text from a task that may
|
|
22
|
+
* have nothing to do with the question. Each chunk carries the task's own title, which is the
|
|
23
|
+
* column the FTS table weights highest.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ The status list is not indexed. "Backlog" and "Done" appear on every board in the
|
|
26
|
+
* installation, so they are the words most likely to match and the least likely to mean anything.
|
|
27
|
+
*/
|
|
28
|
+
function boardChunks(content) {
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(content);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new PermanentIndexingError(`Node version content is not valid JSON: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
35
|
+
}
|
|
36
|
+
const board = BoardDocument.safeParse(parsed);
|
|
37
|
+
if (!board.success) {
|
|
38
|
+
throw new PermanentIndexingError("Node version content is not a valid board document");
|
|
39
|
+
}
|
|
40
|
+
return board.data.tasks.map((task) => ({
|
|
41
|
+
title: task.title,
|
|
42
|
+
text: [task.title, ...task.labels, task.description].filter(Boolean).join("\n\n"),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
16
45
|
async function readCanonical(deps, target) {
|
|
17
46
|
if (target.kind === "attachment") {
|
|
18
47
|
const key = target.contentKeys[0];
|
|
@@ -36,7 +65,9 @@ export function createIndexing(deps) {
|
|
|
36
65
|
// the tree being asked. What makes an agent findable is what a person wrote about it, so the
|
|
37
66
|
// text is its description, and the FTS table indexes the title on its own (#139).
|
|
38
67
|
if (target.kind === "agent") {
|
|
39
|
-
await deps.repository.replace(target,
|
|
68
|
+
await deps.repository.replace(target, [
|
|
69
|
+
{ title: target.title, text: target.description ?? "" },
|
|
70
|
+
]);
|
|
40
71
|
await deps.semantic?.replace(target, target.description ?? "");
|
|
41
72
|
await deps.repository.markIndexed(versionId, deps.now().toISOString());
|
|
42
73
|
return;
|
|
@@ -58,7 +89,11 @@ export function createIndexing(deps) {
|
|
|
58
89
|
if (text === undefined) {
|
|
59
90
|
throw new PermanentIndexingError(`No document converter is configured for ${target.mediaType}`);
|
|
60
91
|
}
|
|
61
|
-
|
|
92
|
+
// ⚠️ The semantic index stays per NODE while the lexical one gains a row per task. Vectorize
|
|
93
|
+
// is keyed by node id (`semantic-index.ts`), so a board would need its own key space there
|
|
94
|
+
// before it could carry one vector per card — a bigger change than #285, and one nothing
|
|
95
|
+
// asks for yet: the lexical half is what "find the card" needs today.
|
|
96
|
+
await deps.repository.replace(target, target.kind === "board" ? boardChunks(text) : [{ title: target.title, text }]);
|
|
62
97
|
await deps.semantic?.replace(target, text);
|
|
63
98
|
await deps.repository.markIndexed(versionId, deps.now().toISOString());
|
|
64
99
|
}
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AddBoardTaskInput, AppendTableRowsInput, ArchiveFlowInput, ArchiveNodeInput, CancelFlowRunInput, CompleteFlowRunStepInput, ConfigureBoardInput, CreateAgentInput, CreateFlowInput, CreateNodeInput, DefineTableInput, DeleteBoardTaskInput, DeleteTableRowsInput, ExecuteToolInput, GetAgentInput, GetBoardInput, GetFlowInput, GetFlowRunInput, GetFlowVersionInput, GetNodeInput, GetNodeVersionInput, GetTableInput, IntelId, ListAgentsInput, ListFlowRunsInput, ListFlowsInput, ListGrantsInput, ListNodesInput, MoveBoardTaskInput, NodeGraphInput, PauseAgentInput, PreviewFlowPublishInput, PublishFlowInput, RedefineTableInput, RelationGraphInput, ResolveNodeLinksInput, RevokeGrantInput, RotateAgentKeyInput, RunAgentNowInput, SaveAgentDefinitionInput, SaveAttachmentInput, SaveFlowVersionInput, SaveNodeVersionInput, SearchInput, ShareInput, StartFlowRunInput, TestToolInput, UnpublishFlowInput, UpdateBoardTaskInput, UpdateFlowInput, UpdateNodeInput, UpdateTableRowsInput, } from "@anchrd/intel-contract";
|
|
2
2
|
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -252,6 +252,21 @@ export async function handleMcp(request, deps) {
|
|
|
252
252
|
openWorldHint: false,
|
|
253
253
|
},
|
|
254
254
|
}, async (input) => text(await deps.nodes.getTable(actor, input.nodeId)));
|
|
255
|
+
// A board answers whole: the statuses and every task, in one read. There is no per-task read
|
|
256
|
+
// beside it, because a card is small and the interesting question is almost always about
|
|
257
|
+
// several of them at once — what is in review, what waits for what (#285).
|
|
258
|
+
server.registerTool("board_get", {
|
|
259
|
+
title: "Get board",
|
|
260
|
+
description: "Read one authorized board: its status list and every task with its full field set. A board that has never been written answers with the default statuses and no tasks.",
|
|
261
|
+
inputSchema: GetBoardInput,
|
|
262
|
+
annotations: {
|
|
263
|
+
title: "Get board",
|
|
264
|
+
readOnlyHint: true,
|
|
265
|
+
destructiveHint: false,
|
|
266
|
+
idempotentHint: true,
|
|
267
|
+
openWorldHint: false,
|
|
268
|
+
},
|
|
269
|
+
}, async (input) => text(await deps.nodes.getBoard(actor, input)));
|
|
255
270
|
registerWithAlias(server, { name: "node_links_list", deprecated: "knowledge_links_list" }, {
|
|
256
271
|
title: "List node links",
|
|
257
272
|
description: "List authorized outgoing links and backlinks for one node.",
|
|
@@ -513,6 +528,79 @@ export async function handleMcp(request, deps) {
|
|
|
513
528
|
openWorldHint: false,
|
|
514
529
|
},
|
|
515
530
|
}, async (input) => text(await deps.nodes.redefineTable(actor, input)));
|
|
531
|
+
/**
|
|
532
|
+
* The board's write surface (#285). No `knowledge_*` alias on any of them — these are new under
|
|
533
|
+
* the post-#125 naming.
|
|
534
|
+
*
|
|
535
|
+
* ⚠️ Not one of them takes a `baseVersionId`, and the descriptions say so. That is the property
|
|
536
|
+
* an agent has to be able to rely on: two agents working the same board at the same time touch
|
|
537
|
+
* different tasks, and neither is asked to read the whole board first or to retry a conflict
|
|
538
|
+
* that says nothing about what they were doing. The server applies each call by task id to the
|
|
539
|
+
* board as it stands at that moment.
|
|
540
|
+
*/
|
|
541
|
+
server.registerTool("board_configure", {
|
|
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.',
|
|
544
|
+
inputSchema: ConfigureBoardInput,
|
|
545
|
+
annotations: {
|
|
546
|
+
title: "Configure board statuses",
|
|
547
|
+
readOnlyHint: false,
|
|
548
|
+
// Removing a column is possible here, so this is not a purely additive write.
|
|
549
|
+
destructiveHint: true,
|
|
550
|
+
idempotentHint: true,
|
|
551
|
+
openWorldHint: false,
|
|
552
|
+
},
|
|
553
|
+
}, async (input) => text(await deps.nodes.configureBoard(actor, input)));
|
|
554
|
+
server.registerTool("board_task_add", {
|
|
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.",
|
|
557
|
+
inputSchema: AddBoardTaskInput,
|
|
558
|
+
annotations: {
|
|
559
|
+
title: "Add board task",
|
|
560
|
+
readOnlyHint: false,
|
|
561
|
+
destructiveHint: false,
|
|
562
|
+
idempotentHint: true,
|
|
563
|
+
openWorldHint: false,
|
|
564
|
+
},
|
|
565
|
+
}, async (input) => text(await deps.nodes.addBoardTask(actor, input)));
|
|
566
|
+
server.registerTool("board_task_update", {
|
|
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.",
|
|
569
|
+
inputSchema: UpdateBoardTaskInput,
|
|
570
|
+
annotations: {
|
|
571
|
+
title: "Update board task",
|
|
572
|
+
readOnlyHint: false,
|
|
573
|
+
// Every named field replaces what stood there; the version history keeps it, the task
|
|
574
|
+
// does not.
|
|
575
|
+
destructiveHint: true,
|
|
576
|
+
idempotentHint: true,
|
|
577
|
+
openWorldHint: false,
|
|
578
|
+
},
|
|
579
|
+
}, async (input) => text(await deps.nodes.updateBoardTask(actor, input)));
|
|
580
|
+
server.registerTool("board_task_move", {
|
|
581
|
+
title: "Move board task",
|
|
582
|
+
description: 'Move one task: into another status, under another parent, or between two neighbours. Archiving a task is this call with status "archived" — there is no separate verb, and nothing is deleted by it. Two moves on two different tasks both go through.',
|
|
583
|
+
inputSchema: MoveBoardTaskInput,
|
|
584
|
+
annotations: {
|
|
585
|
+
title: "Move board task",
|
|
586
|
+
readOnlyHint: false,
|
|
587
|
+
destructiveHint: false,
|
|
588
|
+
idempotentHint: true,
|
|
589
|
+
openWorldHint: false,
|
|
590
|
+
},
|
|
591
|
+
}, async (input) => text(await deps.nodes.moveBoardTask(actor, input)));
|
|
592
|
+
server.registerTool("board_task_delete", {
|
|
593
|
+
title: "Delete board task",
|
|
594
|
+
description: 'Remove a task AND every subtask under it, recursively. The answer counts everything that went, and dependsOn entries in the remaining tasks that pointed at a deleted one are removed with it. To take a task off the board without losing it, move it to the "archived" status instead.',
|
|
595
|
+
inputSchema: DeleteBoardTaskInput,
|
|
596
|
+
annotations: {
|
|
597
|
+
title: "Delete board task",
|
|
598
|
+
readOnlyHint: false,
|
|
599
|
+
destructiveHint: true,
|
|
600
|
+
idempotentHint: true,
|
|
601
|
+
openWorldHint: false,
|
|
602
|
+
},
|
|
603
|
+
}, async (input) => text(await deps.nodes.deleteBoardTask(actor, input)));
|
|
516
604
|
registerWithAlias(server, { name: "node_update", deprecated: "knowledge_update" }, {
|
|
517
605
|
title: "Update node",
|
|
518
606
|
description: "Rename, move, or describe a node.",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { BoardDeps, BoardOperations } from "./board.types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every board operation, applied to the document rather than to the file (#285).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ This module is where a board's rules live, and it is deliberately pure: it takes a document
|
|
6
|
+
* and gives back the next one. The version, R2 and the retry against a racing write are the
|
|
7
|
+
* service's business (`nodes.ts`), and the surfaces have no rules of their own — HTTP, MCP and the
|
|
8
|
+
* UI all arrive here. A second implementation of "may this task depend on that one" is the thing
|
|
9
|
+
* this shape exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ What it does NOT check is anything that needs the tree: whether a `references` id names a node
|
|
12
|
+
* the writer may see, and whether an `agent` assignee is really an agent. Those are reads against
|
|
13
|
+
* D1 and the ACL, so they happen in the service before the document is touched at all.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createBoard(deps: BoardDeps): BoardOperations;
|