@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.
- package/dist/adapters/cloudflare/cloudflare.js +4 -0
- package/dist/adapters/db/db-feed.d.ts +40 -0
- package/dist/adapters/db/db-feed.js +154 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/http/http.js +19 -0
- package/dist/http/http.types.d.ts +2 -0
- package/dist/intel/intel.js +2 -0
- package/dist/intel/intel.types.d.ts +2 -0
- package/dist/mcp/mcp.js +13 -0
- package/dist/mcp/mcp.types.d.ts +2 -0
- package/dist/nodes/nodes.js +12 -11
- package/package.json +2 -2
|
@@ -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,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;
|
package/dist/intel/intel.js
CHANGED
|
@@ -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.",
|
package/dist/mcp/mcp.types.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -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(
|
|
124
|
-
// ⚠️
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
// ⚠️
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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",
|