@anchrd/intel-api 0.12.1 → 0.12.2

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.
@@ -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
- async replace(target, content) {
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
- SELECT ?, ?, ?, ? WHERE EXISTS (
54
- SELECT 1 FROM nodes
55
- WHERE id = ? AND current_version_id = ? AND archived_at IS NULL
56
- )`)
57
- .bind(target.versionId, target.nodeId, target.title, content, target.nodeId, target.versionId),
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) {
@@ -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 of a table, written only against the state it replaces (#135).
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 appendTableSnapshot(input) {
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 ?, ?, ?, ?, ?, ?, ?, 'snapshot', ?, ?
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, version.createdBy, version.createdAt, version.nodeId, input.baseVersionId),
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
- return (result.results ?? []).map((row) => ({
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
- rows.sort((left, right) => (order.get(left.node_id) ?? 0) - (order.get(right.node_id) ?? 0));
1121
- return rows.map((row) => ({
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
- function isCloudflareAccess(issuer) {
28
- return new URL(issuer).hostname.endsWith(".cloudflareaccess.com");
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
- ...(isCloudflareAccess(issuer) ? {} : { scope: "openid profile email offline_access" }),
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
- ...(isCloudflareAccess(input.issuer)
146
- ? {}
147
- : { scope: "openid profile email offline_access" }),
183
+ ...(input.scope ? { scope: input.scope } : {}),
148
184
  token_endpoint_auth_method: "none",
149
- ...(isCloudflareAccess(input.issuer) ? { resource: input.resource } : {}),
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
- const registrationKey = `${issuer}#${resource}`;
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 = {
@@ -15,6 +15,7 @@ export interface OAuthPort {
15
15
  redirectUri: string;
16
16
  clientName: string;
17
17
  resource: string;
18
+ scope?: string;
18
19
  }): Promise<string>;
19
20
  authorizationUrl(input: {
20
21
  issuer: string;
@@ -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 five node kinds.
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, target.description ?? "");
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
- await deps.repository.replace(target, text);
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;