@anchrd/intel-api 0.32.0 → 0.34.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.
@@ -5,6 +5,7 @@ import { createAudit } from "../../audit/audit.js";
5
5
  import { createBrowserAuth } from "../../auth/auth.js";
6
6
  import { createBoards, FIRST_DEFAULT_COLUMN } from "../../boards/boards.js";
7
7
  import { createBundle } from "../../bundle/bundle.js";
8
+ import { createFeed } from "../../feed/feed.js";
8
9
  import { createFlows } from "../../flows/flows.js";
9
10
  import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
10
11
  import { createIntel } from "../../intel/intel.js";
@@ -17,6 +18,7 @@ import { createContentStore } from "../content/content.js";
17
18
  import { createNodeRepository } from "../db/db.js";
18
19
  import { createAuditRepository } from "../db/db-audit.js";
19
20
  import { createBoardRepository } from "../db/db-boards.js";
21
+ import { createFeedRepository } from "../db/db-feed.js";
20
22
  import { createFlowRepository } from "../db/db-flows.js";
21
23
  import { createNodeIndexRepository } from "../db/db-indexing.js";
22
24
  import { createOAuthClientStore } from "../db/db-oauth.js";
@@ -69,6 +71,7 @@ export default {
69
71
  const flowRepository = createFlowRepository({ db: env.DB, now });
70
72
  const nodeRepository = createNodeRepository({ db: env.DB, now });
71
73
  const auditRepository = createAuditRepository({ db: env.DB, now });
74
+ const feedRepository = createFeedRepository({ db: env.DB, now });
72
75
  const boardRepository = createBoardRepository({ db: env.DB, now });
73
76
  const contentStore = createContentStore(env.CONTENT);
74
77
  const gate = createGateClient({ url: env.GATE_URL, serviceKey: env.GATE_SERVICE_KEY });
@@ -257,6 +260,7 @@ export default {
257
260
  flows,
258
261
  tools,
259
262
  audit: createAudit({ audit: auditRepository }),
263
+ feed: createFeed({ feed: feedRepository }),
260
264
  boards: createBoards({
261
265
  boards: boardRepository,
262
266
  nodes,
@@ -0,0 +1,40 @@
1
+ import type { FeedRepository } from "../../feed/feed.types.js";
2
+ import type { D1Database } from "./db.types.js";
3
+ /**
4
+ * One page of the journal, newest first, cut down to the rows this actor may see.
5
+ *
6
+ * ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
7
+ * the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
8
+ * exactly when the node is. A second, similar-looking query beside the first is the drift that
9
+ * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
10
+ *
11
+ * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
12
+ * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
13
+ * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
14
+ * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
15
+ *
16
+ * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
17
+ * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
18
+ * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
19
+ * is invisible on the first pages and only hurts far down.
20
+ */
21
+ export declare const feedPageQuery: string;
22
+ /**
23
+ * The folders above each of a page's nodes, and only the ones the actor may open.
24
+ *
25
+ * ⚠️ **The walk upwards is filtered too, and leaving that out would be a leak.** Grants inherit
26
+ * DOWNWARDS: a grant handed out on a deep node says nothing about the folders above it. Without the
27
+ * join against `allowed`, a breadcrumb would spell out the names of folders the reader may not
28
+ * open — on a surface whose entire premise is that it shows nothing one is not entitled to. A
29
+ * segment that fails the check drops out, and the trail is simply shorter.
30
+ *
31
+ * ⚠️ `UNION` and not `UNION ALL`, and no depth column. A cycle in `parent_id` can reach this table,
32
+ * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
33
+ * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
34
+ * `parent_id`, where a visited-set makes the cycle harmless.
35
+ */
36
+ export declare const feedAncestorsQuery: (seeds: number) => string;
37
+ export declare function createFeedRepository(deps: {
38
+ db: D1Database;
39
+ now(): Date;
40
+ }): FeedRepository;
@@ -0,0 +1,154 @@
1
+ import { FeedAction } from "@anchrd/intel-contract/feed";
2
+ import { subtreeBindings, subtreeCte } from "./db-grants.js";
3
+ // Built once from the contract enum rather than written out here: two lists of actions that have to
4
+ // agree would eventually stop agreeing, and the one that silently wins is the SQL.
5
+ const ACTIONS = FeedAction.options;
6
+ const ACTION_PLACEHOLDERS = ACTIONS.map(() => "?").join(", ");
7
+ function parseMetadata(raw) {
8
+ // The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
9
+ // `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
10
+ // page down with it: the reader would be stuck at that position with no way past. An unreadable
11
+ // payload becomes `{}` and the event still arrives — that something happened is the part a feed
12
+ // cannot afford to lose.
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
16
+ return parsed;
17
+ }
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ return {};
23
+ }
24
+ /**
25
+ * One page of the journal, newest first, cut down to the rows this actor may see.
26
+ *
27
+ * ⚠️ **The visibility check is the same JOIN against `allowed` that `audit_list` uses**, which is
28
+ * the same tree walk `nodes` reads through (`db-grants.ts`). An event about a node is visible
29
+ * exactly when the node is. A second, similar-looking query beside the first is the drift that
30
+ * `anchrd/intel#457` was built out of: two queries with similar names are two different questions.
31
+ *
32
+ * ⚠️ **`ORDER BY` and the cursor comparison name BOTH columns, in the same direction.** Two events
33
+ * in the same millisecond are the normal case inside a batch, not the exception; on `occurred_at`
34
+ * alone the second one is skipped in silence. SQLite has no row-value comparison here, so the
35
+ * tuple comparison is written out: "earlier timestamp, OR same timestamp and smaller id".
36
+ *
37
+ * ⚠️ **Both columns descend, and that is what lets the index carry this.** `audit_events_feed_idx`
38
+ * is `(resource_type, occurred_at, id)` ascending; SQLite reads an index backwards only when every
39
+ * sort column is reversed together. Reversing one would cost a sort over the whole table, and that
40
+ * is invisible on the first pages and only hurts far down.
41
+ */
42
+ export const feedPageQuery = `${subtreeCte}
43
+ SELECT e.id, e.actor_id, e.action, e.resource_id, n.title AS node_title,
44
+ n.parent_id AS node_parent_id, e.metadata_json, e.occurred_at
45
+ FROM audit_events e
46
+ JOIN allowed ON allowed.id = e.resource_id
47
+ JOIN nodes n ON n.id = e.resource_id
48
+ WHERE e.resource_type = 'node'
49
+ AND e.action IN (${ACTION_PLACEHOLDERS})
50
+ AND (? IS NULL OR e.actor_id = ?)
51
+ AND (
52
+ ? IS NULL
53
+ OR e.occurred_at < ?
54
+ OR (e.occurred_at = ? AND e.id < ?)
55
+ )
56
+ ORDER BY e.occurred_at DESC, e.id DESC
57
+ LIMIT ?`;
58
+ /**
59
+ * The folders above each of a page's nodes, and only the ones the actor may open.
60
+ *
61
+ * ⚠️ **The walk upwards is filtered too, and leaving that out would be a leak.** Grants inherit
62
+ * DOWNWARDS: a grant handed out on a deep node says nothing about the folders above it. Without the
63
+ * join against `allowed`, a breadcrumb would spell out the names of folders the reader may not
64
+ * open — on a surface whose entire premise is that it shows nothing one is not entitled to. A
65
+ * segment that fails the check drops out, and the trail is simply shorter.
66
+ *
67
+ * ⚠️ `UNION` and not `UNION ALL`, and no depth column. A cycle in `parent_id` can reach this table,
68
+ * and `UNION` ends the walk because the row repeats — a depth counter would make every row unique
69
+ * and turn the same cycle into a query that never returns. The order is rebuilt in TypeScript from
70
+ * `parent_id`, where a visited-set makes the cycle harmless.
71
+ */
72
+ export const feedAncestorsQuery = (seeds) => `${subtreeCte},
73
+ ancestry(seed, id, parent_id, title) AS (
74
+ SELECT child.id, parent.id, parent.parent_id, parent.title
75
+ FROM nodes child
76
+ JOIN nodes parent ON parent.id = child.parent_id
77
+ WHERE child.id IN (${Array.from({ length: seeds }, () => "?").join(", ")})
78
+ UNION
79
+ SELECT a.seed, grandparent.id, grandparent.parent_id, grandparent.title
80
+ FROM nodes grandparent
81
+ JOIN ancestry a ON a.parent_id = grandparent.id
82
+ )
83
+ SELECT a.seed, a.id, a.parent_id, a.title
84
+ FROM ancestry a
85
+ JOIN allowed ON allowed.id = a.id`;
86
+ /**
87
+ * The chain from the root down to (but not including) the node itself.
88
+ *
89
+ * Built here rather than in SQL because ordering it there would need a depth counter, and a depth
90
+ * counter is what turns a cycle into a hang. `seen` is the same guard on this side.
91
+ *
92
+ * ⚠️ It starts at the node's OWN `parent_id` and not at "some row with this seed": every ancestor
93
+ * of a node carries the same seed, and the order rows come back in is not promised. Picking the
94
+ * first one would put a random grandparent at the near end of the trail.
95
+ *
96
+ * ⚠️ The chain stops where visibility stops. If the actor may read a node but not the folder above
97
+ * it — grants inherit downwards, so a deep grant says nothing about the folders over it — that
98
+ * folder is absent from `rows`, the walk ends, and the trail is short. Short is the honest answer;
99
+ * skipping the gap and carrying on with the grandparent would spell out a place the reader cannot
100
+ * reach.
101
+ */
102
+ function trailFor(parentId, rows) {
103
+ const byId = new Map(rows.map((row) => [row.id, row]));
104
+ const trail = [];
105
+ const seen = new Set();
106
+ let current = parentId === null ? undefined : byId.get(parentId);
107
+ while (current !== undefined && !seen.has(current.id)) {
108
+ seen.add(current.id);
109
+ trail.push({ id: current.id, title: current.title });
110
+ current = current.parent_id === null ? undefined : byId.get(current.parent_id);
111
+ }
112
+ return trail.reverse();
113
+ }
114
+ export function createFeedRepository(deps) {
115
+ return {
116
+ async listEvents(actor, query) {
117
+ const before = query.before ?? null;
118
+ const page = await deps.db
119
+ .prepare(feedPageQuery)
120
+ .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...ACTIONS, query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
121
+ .all();
122
+ const rows = page.results ?? [];
123
+ if (rows.length === 0)
124
+ return { events: [] };
125
+ // One walk for the whole page rather than one per row. The set is at most the page size, and
126
+ // it is deduplicated because a busy morning on one document is exactly the case the feed
127
+ // shows unsummarised today.
128
+ const nodeIds = [...new Set(rows.map((row) => row.resource_id))];
129
+ const ancestors = await deps.db
130
+ .prepare(feedAncestorsQuery(nodeIds.length))
131
+ .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...nodeIds)
132
+ .all();
133
+ const ancestorRows = ancestors.results ?? [];
134
+ const parentOf = new Map(rows.map((row) => [row.resource_id, row.node_parent_id]));
135
+ const trails = new Map(nodeIds.map((id) => [
136
+ id,
137
+ trailFor(parentOf.get(id) ?? null, ancestorRows.filter((row) => row.seed === id)),
138
+ ]));
139
+ const events = rows.map((row) => ({
140
+ id: row.id,
141
+ actorId: row.actor_id,
142
+ // Narrowed at the door by the SQL `IN`, so the cast names what the query already
143
+ // guarantees rather than trusting the column.
144
+ action: row.action,
145
+ nodeId: row.resource_id,
146
+ nodeTitle: row.node_title,
147
+ path: trails.get(row.resource_id) ?? [],
148
+ metadata: parseMetadata(row.metadata_json),
149
+ occurredAt: row.occurred_at,
150
+ }));
151
+ return { events };
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,2 @@
1
+ import type { FeedDeps, FeedService } from "./feed.types.js";
2
+ export declare function createFeed(deps: FeedDeps): FeedService;
@@ -0,0 +1,77 @@
1
+ import { IntelError } from "../shared/intel-error/intel-error.js";
2
+ // The separator inside the encoded cursor. `\0` cannot occur in either half: an ISO timestamp is
3
+ // digits and punctuation, an Intel id is Crockford base32.
4
+ const SEPARATOR = "\0";
5
+ // ⚠️ The marker is what keeps an audit cursor out of this door and a feed cursor out of that one.
6
+ // Both encode the same pair, and without it each would decode the other's position happily — then
7
+ // read on from the wrong END of the journal. The damage would not look like an error: the feed
8
+ // would simply start somewhere in the past and quietly leave out everything since.
9
+ const MARKER = "f1";
10
+ /**
11
+ * The pair, made into one string a reader cannot take apart by accident.
12
+ *
13
+ * ⚠️ This is not encryption and does not pretend to be. Anyone determined can decode base64url and
14
+ * read a timestamp. The point is the accident, not the attacker: a cursor that LOOKS like a
15
+ * timestamp invites a caller to send a timestamp, and a timestamp alone loses every second event
16
+ * written in the same millisecond.
17
+ */
18
+ function encodeCursor(position) {
19
+ const raw = `${MARKER}${SEPARATOR}${position.occurredAt}${SEPARATOR}${position.id}`;
20
+ return btoa(String.fromCharCode(...new TextEncoder().encode(raw)))
21
+ .replaceAll("+", "-")
22
+ .replaceAll("/", "_")
23
+ .replaceAll("=", "");
24
+ }
25
+ function decodeCursor(cursor) {
26
+ // A cursor comes back from a consumer that stored it, possibly weeks ago, possibly truncated by
27
+ // whatever stored it, possibly meant for the other door. Every failure below is the same answer:
28
+ // this is not a cursor this door handed out. Guessing a position from a damaged one would move
29
+ // the reader in silence — past unread events, or back into ones already shown.
30
+ const refuse = () => new IntelError(400, "invalid_cursor", "The cursor is not one the feed issued. Pass back the nextCursor from a previous feed response unchanged, or omit it to start at the most recent event. A cursor from audit_list is not one of these.");
31
+ let raw;
32
+ try {
33
+ const padded = cursor.replaceAll("-", "+").replaceAll("_", "/");
34
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
35
+ raw = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
36
+ }
37
+ catch {
38
+ throw refuse();
39
+ }
40
+ const parts = raw.split(SEPARATOR);
41
+ if (parts.length !== 3)
42
+ throw refuse();
43
+ const [marker, occurredAt, id] = parts;
44
+ if (marker !== MARKER)
45
+ throw refuse();
46
+ // ⚠️ Both halves are checked, not just the split. A cursor whose timestamp decoded to an empty
47
+ // string would compare as "before everything" and answer with an empty page forever.
48
+ if (occurredAt === undefined || occurredAt.length === 0)
49
+ throw refuse();
50
+ if (id === undefined || id.length === 0)
51
+ throw refuse();
52
+ return { occurredAt, id };
53
+ }
54
+ export function createFeed(deps) {
55
+ return {
56
+ async list(actor, input) {
57
+ const before = input.before === undefined ? null : decodeCursor(input.before);
58
+ // One more than asked for, so "is there another page" is answered by the same authorized
59
+ // query instead of a second one that could disagree with it.
60
+ const page = await deps.feed.listEvents(actor, {
61
+ before,
62
+ actor: input.actor ?? null,
63
+ limit: input.limit + 1,
64
+ });
65
+ const hasMore = page.events.length > input.limit;
66
+ const events = hasMore ? page.events.slice(0, input.limit) : page.events;
67
+ const last = events.at(-1);
68
+ return {
69
+ events,
70
+ // ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
71
+ // from the probe would skip exactly one event per page — the off-by-one that shows up as a
72
+ // change nobody saw, not as an error.
73
+ nextCursor: hasMore && last !== undefined ? encodeCursor(last) : null,
74
+ };
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,23 @@
1
+ import type { FeedEvent, FeedListRequest, FeedListResponse } from "@anchrd/intel-contract/feed";
2
+ import type { Actor } from "../nodes/nodes.types.js";
3
+ export interface FeedPosition {
4
+ occurredAt: string;
5
+ id: string;
6
+ }
7
+ export interface FeedQuery {
8
+ before: FeedPosition | null;
9
+ actor: string | null;
10
+ limit: number;
11
+ }
12
+ export interface FeedPage {
13
+ events: FeedEvent[];
14
+ }
15
+ export interface FeedRepository {
16
+ listEvents(actor: Actor, query: FeedQuery): Promise<FeedPage>;
17
+ }
18
+ export interface FeedDeps {
19
+ feed: FeedRepository;
20
+ }
21
+ export interface FeedService {
22
+ list(actor: Actor, input: FeedListRequest): Promise<FeedListResponse>;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/http/http.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AuditListRequest } from "@anchrd/intel-contract/audit";
2
2
  import { BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
3
+ import { FeedListRequest } from "@anchrd/intel-contract/feed";
3
4
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
4
5
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
5
6
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -225,6 +226,24 @@ export function createHttp(deps) {
225
226
  });
226
227
  return context.json(await deps.audit.list(asActor(auth), input));
227
228
  });
229
+ // The same journal read the other way round: newest first, for a person rather than for a
230
+ // consumer catching up (#740). `nodes/read` and nothing new, for the reason `/audit` gives.
231
+ //
232
+ // ⚠️ A SECOND door and not an option on `/audit`. That one is the contract `anchrd/signals` will
233
+ // build on, cursor and direction included; a shared signature would put both readers on one
234
+ // shape, and the next change to it would reach a consumer nobody had in mind.
235
+ app.get("/feed", async (context) => {
236
+ const auth = requireCapability(context, "nodes", "read");
237
+ const limit = context.req.query("limit");
238
+ const before = context.req.query("before");
239
+ const actor = context.req.query("actor");
240
+ const input = FeedListRequest.parse({
241
+ ...(actor === undefined ? {} : { actor }),
242
+ ...(before === undefined ? {} : { before }),
243
+ ...(limit === undefined ? {} : { limit: Number(limit) }),
244
+ });
245
+ return context.json(await deps.feed.list(asActor(auth), input));
246
+ });
228
247
  // The whole board in one answer (#648). `nodes/read` and nothing new: a board is a node, and a
229
248
  // card is a node — a second capability would be a second answer to one question.
230
249
  //
@@ -3,6 +3,7 @@ import type { AuditService } from "../audit/audit.types.js";
3
3
  import type { BrowserAuth } from "../auth/auth.types.js";
4
4
  import type { BoardService } from "../boards/boards.types.js";
5
5
  import type { BundleService } from "../bundle/bundle.types.js";
6
+ import type { FeedService } from "../feed/feed.types.js";
6
7
  import type { FlowService } from "../flows/flows.types.js";
7
8
  import type { NodeService } from "../nodes/nodes.types.js";
8
9
  import type { ToolService } from "../tools/tools.types.js";
@@ -13,6 +14,7 @@ export interface HttpDeps {
13
14
  tools: ToolService;
14
15
  bundle: BundleService;
15
16
  audit: AuditService;
17
+ feed: FeedService;
16
18
  boards: BoardService;
17
19
  resource: string;
18
20
  resourceMetadataUrl: string;
@@ -135,6 +135,7 @@ export function createIntel(deps) {
135
135
  tools: deps.tools,
136
136
  bundle: deps.bundle,
137
137
  audit: deps.audit,
138
+ feed: deps.feed,
138
139
  boards: deps.boards,
139
140
  resource,
140
141
  resourceMetadataUrl,
@@ -184,6 +185,7 @@ export function createIntel(deps) {
184
185
  tools: deps.tools,
185
186
  bundle: deps.bundle,
186
187
  audit: deps.audit,
188
+ feed: deps.feed,
187
189
  boards: deps.boards,
188
190
  });
189
191
  });
@@ -3,6 +3,7 @@ import type { AuditService } from "../audit/audit.types.js";
3
3
  import type { BrowserAuth } from "../auth/auth.types.js";
4
4
  import type { BoardService } from "../boards/boards.types.js";
5
5
  import type { BundleService } from "../bundle/bundle.types.js";
6
+ import type { FeedService } from "../feed/feed.types.js";
6
7
  import type { FlowService } from "../flows/flows.types.js";
7
8
  import type { NodeService } from "../nodes/nodes.types.js";
8
9
  import type { ToolService } from "../tools/tools.types.js";
@@ -15,6 +16,7 @@ export interface IntelDeps {
15
16
  tools: ToolService;
16
17
  bundle: BundleService;
17
18
  audit: AuditService;
19
+ feed: FeedService;
18
20
  boards: BoardService;
19
21
  auth?: BrowserAuth;
20
22
  }
package/dist/mcp/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
2
  import { AuditListRequest } from "@anchrd/intel-contract/audit";
3
3
  import { BoardAssigneeResolveInput, BoardAssigneeSearchInput, BoardGetInput, BoardTaskCreateInput, BoardTaskUpdateInput, BoardUpdateInput, } from "@anchrd/intel-contract/board";
4
+ import { FeedListRequest } from "@anchrd/intel-contract/feed";
4
5
  import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
5
6
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
6
7
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
@@ -263,6 +264,18 @@ export async function handleMcp(request, deps) {
263
264
  openWorldHint: false,
264
265
  },
265
266
  }, async (input) => text(await deps.audit.list(actor, input)));
267
+ server.registerTool("feed_list", {
268
+ title: "List recent activity",
269
+ description: "Read the change journal backwards: what most recently happened to the nodes you may read, newest first, one entry per event. Each entry carries the node's current title and the folders above it, and only the folders you may open. Pass `nextCursor` back as `before` to keep going further into the past; a null `nextCursor` means you have reached the beginning. Pass `actor` to see one person's or one agent's work alone. This is the human-facing view — for catching up on everything in order, use audit_list, and do not mix the two cursors.",
270
+ inputSchema: FeedListRequest,
271
+ annotations: {
272
+ title: "List recent activity",
273
+ readOnlyHint: true,
274
+ destructiveHint: false,
275
+ idempotentHint: true,
276
+ openWorldHint: false,
277
+ },
278
+ }, async (input) => text(await deps.feed.list(actor, input)));
266
279
  server.registerTool("node_list", {
267
280
  title: "List nodes",
268
281
  description: "List authorized folders, documents, attachments, and tables under one parent.",
@@ -2,6 +2,7 @@ import type { Authorized } from "@anchrd/gate-sdk";
2
2
  import type { AuditService } from "../audit/audit.types.js";
3
3
  import type { BoardService } from "../boards/boards.types.js";
4
4
  import type { BundleService } from "../bundle/bundle.types.js";
5
+ import type { FeedService } from "../feed/feed.types.js";
5
6
  import type { FlowService } from "../flows/flows.types.js";
6
7
  import type { NodeService } from "../nodes/nodes.types.js";
7
8
  import type { ToolService } from "../tools/tools.types.js";
@@ -17,5 +18,6 @@ export interface McpDeps {
17
18
  tools: ToolService;
18
19
  bundle: BundleService;
19
20
  audit: AuditService;
21
+ feed: FeedService;
20
22
  boards: BoardService;
21
23
  }
@@ -120,16 +120,17 @@ async function boardFor(deps, actor, parent) {
120
120
  return null;
121
121
  return (await deps.boardOfTask?.(actor, parent.id)) ?? null;
122
122
  }
123
- function refuseArchivedParent(childKind, parent) {
124
- // ⚠️ Tasks only, and the narrowness is deliberate. The same hole exists for a folder archive an
125
- // empty one, file a document in it, purge but it is OLDER than this ticket and fixing it here
126
- // would change the behaviour of the whole tree out of a board ticket, with no test outside
127
- // `boards.int.ts` covering it. It is #679, which also asks what such data already exists.
123
+ function refuseArchivedParent(parent) {
124
+ // ⚠️ EVERY kind, not just tasks (#679). It was narrowed to tasks in #677 because widening it out
125
+ // of a board ticket would have changed the whole tree with no test outside `boards.int.ts`; the
126
+ // hole it left is older than that ticket and is what this one closes.
128
127
  //
129
- // ⚠️ A reference without its number is not a reference: the review of #677 searched for this
130
- // ticket and could not find it, because this comment did not name it.
131
- if (childKind !== "task")
132
- return;
128
+ // ⚠️ **This asks the DIRECT parent only, and the hole one level up stays open** (#733). Archiving a
129
+ // folder that still holds something live is allowed for every kind but `task`, so `Aussen`
130
+ // archived over a live `Innen` still takes anything filed under `Innen` when it is purged:
131
+ // `inspectPurgeTree` walks the whole subtree and `purge` checks `archived_at` on the root alone.
132
+ // Closing that needs a decision (refuse the archive, cascade it, refuse the purge, or name the
133
+ // live nodes in the preview) and #733 carries it with the count of what already exists.
133
134
  if (parent === null || parent.archivedAt === null)
134
135
  return;
135
136
  throw new IntelError(409, "parent_archived", `This ${parent.kind} is archived, so nothing new can be filed under it. Restore it first.`);
@@ -597,7 +598,7 @@ export function createNodes(deps) {
597
598
  // reason — a task lives on a board — and saying "restore the folder first" would send the
598
599
  // reader to do something that changes nothing: restored, the folder still cannot hold a task.
599
600
  refuseWrongParent(input.kind, destinationParent, { destination: destinationBoard });
600
- refuseArchivedParent(input.kind, destinationParent);
601
+ refuseArchivedParent(destinationParent);
601
602
  const timestamp = deps.now().toISOString();
602
603
  const created = await deps.repository.insertNode({
603
604
  node: {
@@ -983,7 +984,7 @@ export function createNodes(deps) {
983
984
  destination: destinationBoard,
984
985
  current: currentBoard,
985
986
  });
986
- refuseArchivedParent(current.kind, parent);
987
+ refuseArchivedParent(parent);
987
988
  }
988
989
  const updatedAt = deps.now().toISOString();
989
990
  const updated = await deps.repository.updateNode({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.32.0",
3
+ "version": "0.34.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.25.0",
46
- "@anchrd/intel-contract": "^0.25.0",
46
+ "@anchrd/intel-contract": "^0.26.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",