@anchrd/intel-api 0.38.0 → 0.40.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/db/db-audit.d.ts +28 -0
- package/dist/adapters/db/db-audit.js +77 -16
- package/dist/adapters/db/db-feed.js +65 -17
- package/dist/adapters/db/db-flows.d.ts +36 -0
- package/dist/adapters/db/db-flows.js +57 -17
- package/dist/adapters/db/db-grants.d.ts +14 -1
- package/dist/adapters/db/db-grants.js +16 -3
- package/dist/audit/audit.js +27 -12
- package/dist/audit/audit.types.d.ts +3 -2
- package/dist/flows/flows.js +7 -2
- package/dist/flows/flows.types.d.ts +1 -1
- package/dist/http/http.js +46 -6
- package/dist/mcp/mcp.js +43 -24
- package/dist/nodes/nodes.js +18 -2
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/package.json +2 -2
|
@@ -28,6 +28,34 @@ import type { D1Database } from "./db.types.js";
|
|
|
28
28
|
* row-value comparison here: "later timestamp, OR same timestamp and larger id".
|
|
29
29
|
*/
|
|
30
30
|
export declare const auditFeedQuery: string;
|
|
31
|
+
/**
|
|
32
|
+
* The same journal about FLOWS (#774).
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ **A separate statement rather than a widened one, and that is the decision.** The three kinds
|
|
35
|
+
* do not differ in a value but in the QUESTION they ask: a node is visible when the node is in
|
|
36
|
+
* `allowed`, a flow when its folder is or it carries a grant of its own, a run one join further on.
|
|
37
|
+
* Written as one query with three branches, every caller would pay all three joins to read one
|
|
38
|
+
* kind — and `audit_list` names exactly one kind per call, so there is nothing to gain from it.
|
|
39
|
+
* The feed is the opposite case and rightly written the opposite way: it reads all three at once.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ **`INNER JOIN flows`, so an event about a purged flow is unreachable** — the same shape the
|
|
42
|
+
* node half has carried since #620, and for the same reason: at a deleted row there is nothing left
|
|
43
|
+
* against which a claim to have been allowed to read it could be tested. Hiding is the safe
|
|
44
|
+
* direction. The feed differs deliberately (it falls back on the purge's own metadata) because a
|
|
45
|
+
* card that says "deleted" is the whole point there; a journal consumer gets the deletion through
|
|
46
|
+
* the parent folder's events instead.
|
|
47
|
+
*/
|
|
48
|
+
export declare const auditFlowFeedQuery: string;
|
|
49
|
+
/**
|
|
50
|
+
* And about RUNS, which is the walk one join further: run → flow → folder (#774).
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ The alias is `run_flow` and it is handed to `flowInSubtreeOver` explicitly. Until #767 that
|
|
53
|
+
* guard had `flow` hard-wired, and against a flow reached through a second join every branch but
|
|
54
|
+
* the folder one compared NULL — a flow somebody OWNS but that hangs at the top level answered "not
|
|
55
|
+
* visible" to its own owner. It fails in the safe direction, which is exactly why it needs a test
|
|
56
|
+
* that seeds a run of a flow at the ROOT rather than in a folder.
|
|
57
|
+
*/
|
|
58
|
+
export declare const auditRunFeedQuery: string;
|
|
31
59
|
export declare function createAuditRepository(deps: {
|
|
32
60
|
db: D1Database;
|
|
33
61
|
now(): Date;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
2
|
-
function mapEvent(row) {
|
|
1
|
+
import { flowInSubtree, flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
|
+
function mapEvent(row, resourceType) {
|
|
3
3
|
let metadata = {};
|
|
4
4
|
// The column is `NOT NULL DEFAULT '{}'`, but it is written by 11 call sites over
|
|
5
5
|
// `JSON.stringify` on whatever each one had at hand. A row that cannot be parsed, or that parses
|
|
@@ -20,7 +20,10 @@ function mapEvent(row) {
|
|
|
20
20
|
id: row.id,
|
|
21
21
|
actorId: row.actor_id,
|
|
22
22
|
action: row.action,
|
|
23
|
-
|
|
23
|
+
// ⚠️ Taken from the QUESTION, not from the row. Each statement names exactly one
|
|
24
|
+
// `resource_type` in its `WHERE`, so the answer cannot carry another — and reading the column
|
|
25
|
+
// back would add a second truth about the same thing (#774).
|
|
26
|
+
resourceType,
|
|
24
27
|
resourceId: row.resource_id,
|
|
25
28
|
metadata,
|
|
26
29
|
occurredAt: row.occurred_at,
|
|
@@ -65,25 +68,83 @@ export const auditFeedQuery = `${subtreeCte}
|
|
|
65
68
|
)
|
|
66
69
|
ORDER BY e.occurred_at ASC, e.id ASC
|
|
67
70
|
LIMIT ?`;
|
|
71
|
+
/**
|
|
72
|
+
* The same journal about FLOWS (#774).
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ **A separate statement rather than a widened one, and that is the decision.** The three kinds
|
|
75
|
+
* do not differ in a value but in the QUESTION they ask: a node is visible when the node is in
|
|
76
|
+
* `allowed`, a flow when its folder is or it carries a grant of its own, a run one join further on.
|
|
77
|
+
* Written as one query with three branches, every caller would pay all three joins to read one
|
|
78
|
+
* kind — and `audit_list` names exactly one kind per call, so there is nothing to gain from it.
|
|
79
|
+
* The feed is the opposite case and rightly written the opposite way: it reads all three at once.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ **`INNER JOIN flows`, so an event about a purged flow is unreachable** — the same shape the
|
|
82
|
+
* node half has carried since #620, and for the same reason: at a deleted row there is nothing left
|
|
83
|
+
* against which a claim to have been allowed to read it could be tested. Hiding is the safe
|
|
84
|
+
* direction. The feed differs deliberately (it falls back on the purge's own metadata) because a
|
|
85
|
+
* card that says "deleted" is the whole point there; a journal consumer gets the deletion through
|
|
86
|
+
* the parent folder's events instead.
|
|
87
|
+
*/
|
|
88
|
+
export const auditFlowFeedQuery = `${subtreeCte}
|
|
89
|
+
SELECT e.id, e.actor_id, e.action, e.resource_id, e.metadata_json, e.occurred_at
|
|
90
|
+
FROM audit_events e
|
|
91
|
+
JOIN flows flow ON flow.id = e.resource_id
|
|
92
|
+
WHERE e.resource_type = 'flow'
|
|
93
|
+
AND ${flowInSubtree}
|
|
94
|
+
AND (
|
|
95
|
+
? IS NULL
|
|
96
|
+
OR e.occurred_at > ?
|
|
97
|
+
OR (e.occurred_at = ? AND e.id > ?)
|
|
98
|
+
)
|
|
99
|
+
ORDER BY e.occurred_at ASC, e.id ASC
|
|
100
|
+
LIMIT ?`;
|
|
101
|
+
/**
|
|
102
|
+
* And about RUNS, which is the walk one join further: run → flow → folder (#774).
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ The alias is `run_flow` and it is handed to `flowInSubtreeOver` explicitly. Until #767 that
|
|
105
|
+
* guard had `flow` hard-wired, and against a flow reached through a second join every branch but
|
|
106
|
+
* the folder one compared NULL — a flow somebody OWNS but that hangs at the top level answered "not
|
|
107
|
+
* visible" to its own owner. It fails in the safe direction, which is exactly why it needs a test
|
|
108
|
+
* that seeds a run of a flow at the ROOT rather than in a folder.
|
|
109
|
+
*/
|
|
110
|
+
export const auditRunFeedQuery = `${subtreeCte}
|
|
111
|
+
SELECT e.id, e.actor_id, e.action, e.resource_id, e.metadata_json, e.occurred_at
|
|
112
|
+
FROM audit_events e
|
|
113
|
+
JOIN flow_runs run ON run.id = e.resource_id
|
|
114
|
+
JOIN flows run_flow ON run_flow.id = run.flow_id
|
|
115
|
+
WHERE e.resource_type = 'flow-run'
|
|
116
|
+
AND ${flowInSubtreeOver("run_flow.parent_id", "run_flow")}
|
|
117
|
+
AND (
|
|
118
|
+
? IS NULL
|
|
119
|
+
OR e.occurred_at > ?
|
|
120
|
+
OR (e.occurred_at = ? AND e.id > ?)
|
|
121
|
+
)
|
|
122
|
+
ORDER BY e.occurred_at ASC, e.id ASC
|
|
123
|
+
LIMIT ?`;
|
|
68
124
|
export function createAuditRepository(deps) {
|
|
69
|
-
const page = auditFeedQuery;
|
|
70
125
|
return {
|
|
71
|
-
async
|
|
126
|
+
async listEvents(actor, query) {
|
|
72
127
|
const after = query.after ?? null;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
128
|
+
const now = deps.now().toISOString();
|
|
129
|
+
// ⚠️ One reading of the clock for the whole call. Both guards measure grant expiry against
|
|
130
|
+
// it, and two `now()` readings could straddle the moment a grant runs out.
|
|
131
|
+
const guard = query.resourceType === "node"
|
|
132
|
+
? { page: auditFeedQuery, bindings: subtreeBindings(actor, "read", now) }
|
|
133
|
+
: {
|
|
134
|
+
page: query.resourceType === "flow" ? auditFlowFeedQuery : auditRunFeedQuery,
|
|
135
|
+
// The flow walks need the CTE's bindings and the guard's, in that order — the guard
|
|
136
|
+
// is written into the statement after the CTE.
|
|
137
|
+
bindings: [
|
|
138
|
+
...subtreeBindings(actor, "read", now),
|
|
139
|
+
...flowInSubtreeBindings(actor, "read", now),
|
|
140
|
+
],
|
|
141
|
+
};
|
|
81
142
|
const result = await deps.db
|
|
82
|
-
.prepare(page)
|
|
83
|
-
.bind(...bindings)
|
|
143
|
+
.prepare(guard.page)
|
|
144
|
+
.bind(...guard.bindings, after === null ? null : after.occurredAt, after === null ? null : after.occurredAt, after === null ? null : after.occurredAt, after === null ? null : after.id, query.limit)
|
|
84
145
|
.all();
|
|
85
146
|
const rows = result.results ?? [];
|
|
86
|
-
return { events: rows.map(mapEvent) };
|
|
147
|
+
return { events: rows.map((row) => mapEvent(row, query.resourceType)) };
|
|
87
148
|
},
|
|
88
149
|
};
|
|
89
150
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import { FeedAction,
|
|
1
|
+
import { FeedAction, feedResourceTypeOf, } from "@anchrd/intel-contract/feed";
|
|
2
2
|
import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
3
|
-
// The
|
|
3
|
+
// The three kinds of resource the feed reads, split out of the one action list so the SQL can name
|
|
4
4
|
// each side by itself. Derived from the contract rather than written again here: one list that has
|
|
5
5
|
// to agree with another is one list too many.
|
|
6
|
-
const NODE_ACTIONS = FeedAction.options.filter((action) =>
|
|
7
|
-
const FLOW_ACTIONS = FeedAction.options.filter((action) =>
|
|
6
|
+
const NODE_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "node");
|
|
7
|
+
const FLOW_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow");
|
|
8
|
+
// ⚠️ The third kind (#767). These are cards ABOUT a flow — `feedKindOf` says `flow` — written as
|
|
9
|
+
// rows about a RUN, so they are found under their own `resource_type` and nowhere near the two
|
|
10
|
+
// lists above. Splitting on `feedResourceTypeOf` rather than on `feedKindOf` is what keeps them out
|
|
11
|
+
// of the flow branch, where `flow.id = e.resource_id` would never match a run id and every one of
|
|
12
|
+
// them would vanish without a word.
|
|
13
|
+
const RUN_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow-run");
|
|
8
14
|
const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
|
|
9
15
|
const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
|
|
16
|
+
const RUN_PLACEHOLDERS = RUN_ACTIONS.map(() => "?").join(", ");
|
|
10
17
|
function parseMetadata(raw) {
|
|
11
18
|
// The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
|
|
12
19
|
// `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
|
|
@@ -56,9 +63,10 @@ function parseMetadata(raw) {
|
|
|
56
63
|
* the whole table, and that is invisible on the first pages and only hurts far down.
|
|
57
64
|
*
|
|
58
65
|
* ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
|
|
59
|
-
* The feed reads
|
|
60
|
-
* `IN ('node', 'flow')` — or, as written here, under
|
|
61
|
-
* stops being an equality prefix, and the ordering falls off it. Measured before
|
|
66
|
+
* The feed reads all three kinds at once (#767), and the older index leads with `resource_type`;
|
|
67
|
+
* under `IN ('node', 'flow', 'flow-run')` — or, as written here, under three branches joined by
|
|
68
|
+
* `OR` — that column stops being an equality prefix, and the ordering falls off it. Measured before
|
|
69
|
+
* `0026` existed:
|
|
62
70
|
* `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
|
|
63
71
|
* `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
|
|
64
72
|
*
|
|
@@ -66,14 +74,15 @@ function parseMetadata(raw) {
|
|
|
66
74
|
* the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
|
|
67
75
|
* `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
|
|
68
76
|
* the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
|
|
69
|
-
* whole table. Measured both ways. Filtering on kind is cheap here
|
|
70
|
-
* sorting the journal is not.
|
|
77
|
+
* whole table. Measured both ways. Filtering on kind is cheap here; sorting the journal is not.
|
|
71
78
|
*
|
|
72
|
-
* ⚠️ **The
|
|
73
|
-
*
|
|
79
|
+
* ⚠️ **The three branches ask three DIFFERENT authorization questions, and none may stand in for
|
|
80
|
+
* another.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
|
|
74
81
|
* when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
|
|
75
82
|
* `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
|
|
76
|
-
* exist.
|
|
83
|
+
* exist. A RUN is visible when the flow it belongs to is, which takes one join more to reach
|
|
84
|
+
* (#767). The first two fall back on the metadata `parentId` for the one event whose row is gone;
|
|
85
|
+
* the third deliberately does not — see `RUN_PARENT`.
|
|
77
86
|
*/
|
|
78
87
|
const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
|
|
79
88
|
// ⚠️ A card whose title is NULL names nothing, and `FeedEvent.resourceTitle` refuses it — the reader
|
|
@@ -86,17 +95,46 @@ const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.p
|
|
|
86
95
|
// ⚠️ The node half cannot reach this state, and that asymmetry is the whole reason it was missed:
|
|
87
96
|
// there the check is `COALESCE(n.id, …) IN (SELECT id FROM allowed)`, a join against real rows with
|
|
88
97
|
// no admin short-circuit in front of it, so a deleted node's events drop out on their own.
|
|
89
|
-
const HAS_TITLE = "COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
|
|
98
|
+
const HAS_TITLE = "COALESCE(n.title, flow.title, run_flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
|
|
90
99
|
const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
|
|
100
|
+
/**
|
|
101
|
+
* The third walk (#767): the run's flow, and that flow's folder.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ There is **no metadata fallback here**, and that is the decision rather than an omission. The
|
|
104
|
+
* other two kinds fall back on a `parentId` the purge wrote, because a purged node or flow still
|
|
105
|
+
* has events worth reading. A run has no purge of its own — it disappears when its flow is purged,
|
|
106
|
+
* and `flows.purge` already carries that news as its own card. An event whose run row is gone
|
|
107
|
+
* therefore drops out on `HAS_TITLE`, which is the safe direction: the alternative is a card naming
|
|
108
|
+
* a flow nobody can check the reader was allowed to see.
|
|
109
|
+
*/
|
|
110
|
+
const RUN_PARENT = "run_flow.parent_id";
|
|
111
|
+
/**
|
|
112
|
+
* What a run card POINTS at: the flow, never the run (#767).
|
|
113
|
+
*
|
|
114
|
+
* Everything else about such a card already names the flow — its title, its path — and the surface
|
|
115
|
+
* turns `resourceId` into a link through `feedKindOf`, which answers `flow` for these two actions.
|
|
116
|
+
* Left as the run id it would navigate to `/flows?select=<run id>`, where nothing matches: an empty
|
|
117
|
+
* pane with no error, which `feed.tsx` names the worst of the three possible outcomes. There is no
|
|
118
|
+
* route onto a single run, so the flow is the honest target rather than a second-best one.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ `COALESCE` and not a `CASE` on the resource type: `run` is only joined for `flow-run` rows, so
|
|
121
|
+
* it is NULL everywhere else and every other card keeps its own id untouched.
|
|
122
|
+
*/
|
|
123
|
+
const RUN_TARGET = "COALESCE(run.flow_id, e.resource_id)";
|
|
91
124
|
export const feedPageQuery = `${subtreeCte}
|
|
92
|
-
SELECT e.id, e.actor_id, e.action,
|
|
93
|
-
|
|
94
|
-
COALESCE(n.
|
|
125
|
+
SELECT e.id, e.actor_id, e.action,
|
|
126
|
+
${RUN_TARGET} AS resource_id,
|
|
127
|
+
COALESCE(n.title, flow.title, run_flow.title,
|
|
128
|
+
json_extract(e.metadata_json, '$.title')) AS resource_title,
|
|
129
|
+
COALESCE(n.parent_id, flow.parent_id, run_flow.parent_id,
|
|
130
|
+
json_extract(e.metadata_json, '$.parentId'))
|
|
95
131
|
AS resource_parent_id,
|
|
96
132
|
e.metadata_json, e.occurred_at
|
|
97
133
|
FROM audit_events e
|
|
98
134
|
LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
|
|
99
135
|
LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
|
|
136
|
+
LEFT JOIN flow_runs run ON run.id = e.resource_id AND e.resource_type = 'flow-run'
|
|
137
|
+
LEFT JOIN flows run_flow ON run_flow.id = run.flow_id
|
|
100
138
|
WHERE (
|
|
101
139
|
(
|
|
102
140
|
+e.resource_type = 'node'
|
|
@@ -108,6 +146,11 @@ export const feedPageQuery = `${subtreeCte}
|
|
|
108
146
|
AND e.action IN (${FLOW_PLACEHOLDERS})
|
|
109
147
|
AND ${flowInSubtreeOver(FLOW_PARENT)}
|
|
110
148
|
)
|
|
149
|
+
OR (
|
|
150
|
+
+e.resource_type = 'flow-run'
|
|
151
|
+
AND e.action IN (${RUN_PLACEHOLDERS})
|
|
152
|
+
AND ${flowInSubtreeOver(RUN_PARENT, "run_flow")}
|
|
153
|
+
)
|
|
111
154
|
)
|
|
112
155
|
AND ${HAS_TITLE}
|
|
113
156
|
AND (? IS NULL OR e.actor_id = ?)
|
|
@@ -191,7 +234,12 @@ export function createFeedRepository(deps) {
|
|
|
191
234
|
const now = deps.now().toISOString();
|
|
192
235
|
const page = await deps.db
|
|
193
236
|
.prepare(feedPageQuery)
|
|
194
|
-
.bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now),
|
|
237
|
+
.bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), ...RUN_ACTIONS,
|
|
238
|
+
// ⚠️ A SECOND set of bindings for the same guard: `flowInSubtreeOver` writes its
|
|
239
|
+
// placeholders out again for the run branch, and positional binding counts every one of
|
|
240
|
+
// them. Reusing the first set would shift every parameter after it by the width of the
|
|
241
|
+
// guard — which is not a type error and not a syntax error, only wrong answers.
|
|
242
|
+
...flowInSubtreeBindings(actor, "read", now), 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)
|
|
195
243
|
.all();
|
|
196
244
|
const rows = page.results ?? [];
|
|
197
245
|
if (rows.length === 0)
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import type { FlowRepository } from "../../flows/flows.types.js";
|
|
2
2
|
import type { D1Database } from "./db.types.js";
|
|
3
|
+
/**
|
|
4
|
+
* One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
|
|
5
|
+
* (#774, found in the review of #787).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
|
|
8
|
+
* naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
|
|
9
|
+
* it never sees the value — so an `OR` on the leading index column drops that column as an equality
|
|
10
|
+
* prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
|
|
11
|
+
* engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
|
|
12
|
+
*
|
|
13
|
+
* -- WHERE run.flow_id = ?
|
|
14
|
+
* SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
|
|
15
|
+
*
|
|
16
|
+
* -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
|
|
17
|
+
* SCAN run | USE TEMP B-TREE FOR ORDER BY
|
|
18
|
+
*
|
|
19
|
+
* The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
|
|
20
|
+
* along and is the frequent call, and it would have started scanning every run in the installation
|
|
21
|
+
* without a single test going red. `db-audit.ts` had the same choice one file over and split its
|
|
22
|
+
* statements per kind; this is that decision, applied where the review found it missing.
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
|
|
25
|
+
* whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
|
|
26
|
+
* id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
|
|
27
|
+
* ordering has to come from somewhere.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
|
|
30
|
+
* they were already written this way. An absent clause binds nothing, so every caller of this
|
|
31
|
+
* function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
|
|
32
|
+
* id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
|
|
33
|
+
*/
|
|
34
|
+
export declare function flowRunsPageQuery(shape: {
|
|
35
|
+
scoped: boolean;
|
|
36
|
+
failedOnly: boolean;
|
|
37
|
+
paged: boolean;
|
|
38
|
+
}): string;
|
|
3
39
|
export declare function createFlowRepository(deps: {
|
|
4
40
|
db: D1Database;
|
|
5
41
|
now(): Date;
|
|
@@ -74,6 +74,47 @@ function mapVersion(row) {
|
|
|
74
74
|
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's. The bindings
|
|
75
75
|
// live in the factory below because `flowInSubtree` now needs the moment expiry is measured against.
|
|
76
76
|
const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
|
|
77
|
+
/**
|
|
78
|
+
* One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
|
|
79
|
+
* (#774, found in the review of #787).
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
|
|
82
|
+
* naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
|
|
83
|
+
* it never sees the value — so an `OR` on the leading index column drops that column as an equality
|
|
84
|
+
* prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
|
|
85
|
+
* engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
|
|
86
|
+
*
|
|
87
|
+
* -- WHERE run.flow_id = ?
|
|
88
|
+
* SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
|
|
89
|
+
*
|
|
90
|
+
* -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
|
|
91
|
+
* SCAN run | USE TEMP B-TREE FOR ORDER BY
|
|
92
|
+
*
|
|
93
|
+
* The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
|
|
94
|
+
* along and is the frequent call, and it would have started scanning every run in the installation
|
|
95
|
+
* without a single test going red. `db-audit.ts` had the same choice one file over and split its
|
|
96
|
+
* statements per kind; this is that decision, applied where the review found it missing.
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
|
|
99
|
+
* whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
|
|
100
|
+
* id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
|
|
101
|
+
* ordering has to come from somewhere.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
|
|
104
|
+
* they were already written this way. An absent clause binds nothing, so every caller of this
|
|
105
|
+
* function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
|
|
106
|
+
* id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
|
|
107
|
+
*/
|
|
108
|
+
export function flowRunsPageQuery(shape) {
|
|
109
|
+
return `${subtreeCte}
|
|
110
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
111
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
112
|
+
WHERE ${shape.scoped ? "run.flow_id = ? AND " : ""}${runVisible}
|
|
113
|
+
${shape.failedOnly ? "AND run.status = 'failed'" : ""}
|
|
114
|
+
${shape.paged ? "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))" : ""}
|
|
115
|
+
ORDER BY run.created_at DESC, run.id DESC
|
|
116
|
+
LIMIT ?`;
|
|
117
|
+
}
|
|
77
118
|
function mapStep(row) {
|
|
78
119
|
return {
|
|
79
120
|
runId: row.run_id,
|
|
@@ -983,29 +1024,28 @@ export function createFlowRepository(deps) {
|
|
|
983
1024
|
.first();
|
|
984
1025
|
return row ? mapRun(row) : null;
|
|
985
1026
|
},
|
|
986
|
-
// Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving
|
|
987
|
-
// and so `flow_runs_flow_idx (flow_id, created_at DESC)` is the index that serves it.
|
|
1027
|
+
// Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving.
|
|
988
1028
|
//
|
|
989
1029
|
// ⚠️ `runVisible`, the same predicate `getRunVisible` uses one function up. Seeing the flow is
|
|
990
1030
|
// not the same as seeing its runs: a library flow is executable for everyone and readable by
|
|
991
1031
|
// nobody, and the people running it must still find their own runs back.
|
|
1032
|
+
//
|
|
1033
|
+
// ⚠️ A null flow means EVERY flow this actor may read, not "no flow" (#774). The row filter is
|
|
1034
|
+
// untouched by it — `runVisible` decides per row either way — so the wider question cannot
|
|
1035
|
+
// answer with a run the narrow one would have hidden. Which SHAPE of statement asks it is
|
|
1036
|
+
// `flowRunsPageQuery`'s business, and the reason it is a shape rather than a binding is written
|
|
1037
|
+
// there.
|
|
992
1038
|
async listRunsVisible(actor, input) {
|
|
993
|
-
const
|
|
994
|
-
? {
|
|
995
|
-
clause: "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))",
|
|
996
|
-
bindings: [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id],
|
|
997
|
-
}
|
|
998
|
-
: { clause: "", bindings: [] };
|
|
1039
|
+
const scoped = input.flowId !== null;
|
|
999
1040
|
const result = await deps.db
|
|
1000
|
-
.prepare(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
.bind(...readableBindings(actor), input.flowId, ...runVisibleBindings(actor), ...page.bindings, input.limit)
|
|
1041
|
+
.prepare(flowRunsPageQuery({
|
|
1042
|
+
scoped,
|
|
1043
|
+
failedOnly: input.failedOnly,
|
|
1044
|
+
paged: input.cursor !== null && input.cursor !== undefined,
|
|
1045
|
+
}))
|
|
1046
|
+
.bind(...readableBindings(actor), ...(scoped ? [input.flowId] : []), ...runVisibleBindings(actor), ...(input.cursor
|
|
1047
|
+
? [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id]
|
|
1048
|
+
: []), input.limit)
|
|
1009
1049
|
.all();
|
|
1010
1050
|
return (result.results ?? []).map(mapRun);
|
|
1011
1051
|
},
|
|
@@ -115,6 +115,19 @@ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb
|
|
|
115
115
|
* expressions with similar names are two different questions, and the one that goes stale is the
|
|
116
116
|
* copy.
|
|
117
117
|
*/
|
|
118
|
-
|
|
118
|
+
/**
|
|
119
|
+
* ⚠️ **The ALIAS is a parameter too, since #767, and leaving it hard-wired was a real fault rather
|
|
120
|
+
* than an inelegance.** The feed's third branch asks about a flow reached through its RUN, so the
|
|
121
|
+
* flow sits under a second join (`run_flow`) while `flow` is NULL on that row. Against the
|
|
122
|
+
* hard-wired name the owner branch and the direct-grant branch both compared NULL — so a flow
|
|
123
|
+
* somebody OWNS but that hangs at the top level, with no folder above it to carry a grant, answered
|
|
124
|
+
* "not visible" to its own owner.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ It fails in the safe direction, which is exactly what made it worth a test rather than a
|
|
127
|
+
* comment: nothing leaks, a row is only ever missing. A flow filed in a readable FOLDER answers
|
|
128
|
+
* correctly through the third branch, so every test with a folder is green either way — and the two
|
|
129
|
+
* tests that caught it are the ones that seed a flow at the root.
|
|
130
|
+
*/
|
|
131
|
+
export declare function flowInSubtreeOver(parentExpression: string, flowAlias?: string): string;
|
|
119
132
|
export declare const flowInSubtree: string;
|
|
120
133
|
export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
|
|
@@ -242,12 +242,25 @@ export function flowVerbBindings(flowId, actor, verb, now) {
|
|
|
242
242
|
* expressions with similar names are two different questions, and the one that goes stale is the
|
|
243
243
|
* copy.
|
|
244
244
|
*/
|
|
245
|
-
|
|
245
|
+
/**
|
|
246
|
+
* ⚠️ **The ALIAS is a parameter too, since #767, and leaving it hard-wired was a real fault rather
|
|
247
|
+
* than an inelegance.** The feed's third branch asks about a flow reached through its RUN, so the
|
|
248
|
+
* flow sits under a second join (`run_flow`) while `flow` is NULL on that row. Against the
|
|
249
|
+
* hard-wired name the owner branch and the direct-grant branch both compared NULL — so a flow
|
|
250
|
+
* somebody OWNS but that hangs at the top level, with no folder above it to carry a grant, answered
|
|
251
|
+
* "not visible" to its own owner.
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ It fails in the safe direction, which is exactly what made it worth a test rather than a
|
|
254
|
+
* comment: nothing leaks, a row is only ever missing. A flow filed in a readable FOLDER answers
|
|
255
|
+
* correctly through the third branch, so every test with a folder is green either way — and the two
|
|
256
|
+
* tests that caught it are the ones that seed a flow at the root.
|
|
257
|
+
*/
|
|
258
|
+
export function flowInSubtreeOver(parentExpression, flowAlias = "flow") {
|
|
246
259
|
return `(
|
|
247
260
|
? = 1
|
|
248
|
-
OR
|
|
261
|
+
OR ${flowAlias}.owner_id = ?
|
|
249
262
|
OR ${parentExpression} IN (SELECT id FROM allowed)
|
|
250
|
-
OR ${flowGrantExists(
|
|
263
|
+
OR ${flowGrantExists(`${flowAlias}.id`)}
|
|
251
264
|
)`;
|
|
252
265
|
}
|
|
253
266
|
export const flowInSubtree = flowInSubtreeOver("flow.parent_id");
|
package/dist/audit/audit.js
CHANGED
|
@@ -47,25 +47,40 @@ function decodeCursor(cursor) {
|
|
|
47
47
|
export function createAudit(deps) {
|
|
48
48
|
return {
|
|
49
49
|
async list(actor, input) {
|
|
50
|
-
// `resourceType` is validated by the contract enum before it reaches here; the value is
|
|
51
|
-
// named again rather than assumed, so adding `flow` to the enum later cannot silently route
|
|
52
|
-
// flow events through the node visibility walk.
|
|
53
|
-
if (input.resourceType !== "node") {
|
|
54
|
-
throw new IntelError(400, "unsupported_resource_type", "Only node events can be listed today. Flow and flow-run events need their own visibility check and are refused rather than omitted, so that an empty answer never has to mean two different things.");
|
|
55
|
-
}
|
|
56
50
|
const after = input.after === undefined ? null : decodeCursor(input.after);
|
|
57
|
-
//
|
|
51
|
+
// ⚠️ The refusal that stood here until #774 is GONE, and it was doing real work: it named
|
|
52
|
+
// `node` explicitly so that widening the contract enum could not silently route flow events
|
|
53
|
+
// through the node visibility walk. What replaces it is not trust but a repository that takes
|
|
54
|
+
// the kind as an argument and picks a statement per kind — each with its own walk, none able
|
|
55
|
+
// to answer for another. Adding a fourth kind to the enum is now a type error there rather
|
|
56
|
+
// than a wrong answer here, which is the stronger version of the same guard.
|
|
57
|
+
//
|
|
58
|
+
// One more row than asked for, so "is there another page" is answered by the same authorized
|
|
58
59
|
// query instead of a second one that could disagree with it.
|
|
59
|
-
const page = await deps.audit.
|
|
60
|
+
const page = await deps.audit.listEvents(actor, {
|
|
61
|
+
after,
|
|
62
|
+
resourceType: input.resourceType,
|
|
63
|
+
limit: input.limit + 1,
|
|
64
|
+
});
|
|
60
65
|
const hasMore = page.events.length > input.limit;
|
|
61
66
|
const events = hasMore ? page.events.slice(0, input.limit) : page.events;
|
|
62
67
|
const last = events.at(-1);
|
|
63
68
|
return {
|
|
64
69
|
events,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
|
|
72
|
+
* from the probe would skip exactly one event per page — the classic off-by-one that shows
|
|
73
|
+
* up as a lead nobody contacted, not as an error.
|
|
74
|
+
*
|
|
75
|
+
* ⚠️ **And it is returned whenever rows were delivered, not only when more follows.** It
|
|
76
|
+
* used to carry `hasMore &&`, which meant a reader catching up received fifty events and no
|
|
77
|
+
* position: it started over on its next run and would have delivered the same fifty for
|
|
78
|
+
* ever. A reader paging through never sees it, because it always asks for a full page —
|
|
79
|
+
* which is why this survived until `anchrd/signals` read the journal for real (`#793`).
|
|
80
|
+
*/
|
|
81
|
+
nextCursor: last === undefined ? null : encodeCursor(last),
|
|
82
|
+
// Whether another page may follow. Its own field, because it is its own question.
|
|
83
|
+
hasMore,
|
|
69
84
|
};
|
|
70
85
|
},
|
|
71
86
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuditEvent, AuditListRequest, AuditListResponse } from "@anchrd/intel-contract/audit";
|
|
1
|
+
import type { AuditEvent, AuditListRequest, AuditListResponse, AuditResourceType } from "@anchrd/intel-contract/audit";
|
|
2
2
|
import type { Actor } from "../nodes/nodes.types.js";
|
|
3
3
|
export interface AuditPosition {
|
|
4
4
|
occurredAt: string;
|
|
@@ -6,13 +6,14 @@ export interface AuditPosition {
|
|
|
6
6
|
}
|
|
7
7
|
export interface AuditQuery {
|
|
8
8
|
after: AuditPosition | null;
|
|
9
|
+
resourceType: AuditResourceType;
|
|
9
10
|
limit: number;
|
|
10
11
|
}
|
|
11
12
|
export interface AuditPage {
|
|
12
13
|
events: AuditEvent[];
|
|
13
14
|
}
|
|
14
15
|
export interface AuditRepository {
|
|
15
|
-
|
|
16
|
+
listEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
|
|
16
17
|
}
|
|
17
18
|
export interface AuditDeps {
|
|
18
19
|
audit: AuditRepository;
|
package/dist/flows/flows.js
CHANGED
|
@@ -1776,11 +1776,16 @@ export function createFlows(deps) {
|
|
|
1776
1776
|
async listRuns(actor, input) {
|
|
1777
1777
|
// The history of a flow that has since been archived stays readable — the runs happened, and
|
|
1778
1778
|
// archiving is about what may start next (#112).
|
|
1779
|
-
|
|
1779
|
+
//
|
|
1780
|
+
// ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
|
|
1781
|
+
// answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
|
|
1782
|
+
// while which runs appear is decided per row in the repository — their own, plus every run of
|
|
1783
|
+
// a flow they may read. Asking it for a flow nobody named would mean inventing one.
|
|
1784
|
+
const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
|
|
1780
1785
|
// One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
|
|
1781
1786
|
// count over the whole table is never needed to draw a "next" affordance.
|
|
1782
1787
|
const rows = await deps.repository.listRunsVisible(actor, {
|
|
1783
|
-
flowId
|
|
1788
|
+
flowId,
|
|
1784
1789
|
failedOnly: input.failedOnly,
|
|
1785
1790
|
limit: input.limit + 1,
|
|
1786
1791
|
cursor: decodeCursor(input.cursor),
|
package/dist/http/http.js
CHANGED
|
@@ -207,16 +207,25 @@ export function createHttp(deps) {
|
|
|
207
207
|
const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
|
|
208
208
|
return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
|
|
209
209
|
});
|
|
210
|
-
// The change journal, read forward from a stable position (#620).
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// the
|
|
210
|
+
// The change journal, read forward from a stable position (#620).
|
|
211
|
+
//
|
|
212
|
+
// ⚠️ **The capability follows the KIND asked for** (#774). An audit row says the same thing its
|
|
213
|
+
// resource says, so the journal asks what reading that resource asks: `nodes/read` for `node`,
|
|
214
|
+
// `flows/read` for `flow` and `flow-run` — which is what every other flow-delivering route in
|
|
215
|
+
// this file requires. Until the enum was widened one capability covered the one kind there was;
|
|
216
|
+
// leaving it at `nodes/read` afterwards would have dropped the first of the three checks for
|
|
217
|
+
// exactly the two kinds this ticket opened, and left the Intel ACL as the only filter.
|
|
218
|
+
//
|
|
219
|
+
// ⚠️ The parse therefore runs BEFORE the capability check, which is the one place in this file
|
|
220
|
+
// where it does. That is not a loosening of "capability first, so a refusal costs no storage
|
|
221
|
+
// read": parsing touches the query string and nothing else, and the kind cannot be known any
|
|
222
|
+
// earlier than this. What it costs is that a malformed `resourceType` answers 400 rather than
|
|
223
|
+
// 403 — a sentence about the caller's own parameter, which names nothing of Intel's.
|
|
214
224
|
//
|
|
215
225
|
// ⚠️ GET with the cursor in the query string, because the verb is one of the four in the grammar
|
|
216
226
|
// and the method carries it. `limit` arrives as a string and is coerced here — the contract
|
|
217
227
|
// wants a number, and a query string has none.
|
|
218
228
|
app.get("/audit", async (context) => {
|
|
219
|
-
const auth = requireCapability(context, "nodes", "read");
|
|
220
229
|
const limit = context.req.query("limit");
|
|
221
230
|
const after = context.req.query("after");
|
|
222
231
|
const input = AuditListRequest.parse({
|
|
@@ -224,10 +233,19 @@ export function createHttp(deps) {
|
|
|
224
233
|
...(after === undefined ? {} : { after }),
|
|
225
234
|
...(limit === undefined ? {} : { limit: Number(limit) }),
|
|
226
235
|
});
|
|
236
|
+
const auth = requireCapability(context, input.resourceType === "node" ? "nodes" : "flows", "read");
|
|
227
237
|
return context.json(await deps.audit.list(asActor(auth), input));
|
|
228
238
|
});
|
|
229
239
|
// The same journal read the other way round: newest first, for a person rather than for a
|
|
230
|
-
// consumer catching up (#740).
|
|
240
|
+
// consumer catching up (#740).
|
|
241
|
+
//
|
|
242
|
+
// ⚠️ `nodes/read` for the whole answer, and here that CANNOT follow the kind the way `/audit`
|
|
243
|
+
// above does: one page mixes node and flow cards on purpose, so there is no single kind to pick a
|
|
244
|
+
// capability from. It has been that way since flow cards existed and #767 only added two actions
|
|
245
|
+
// to them, so nothing about it is new — but it does mean somebody holding `nodes/read` without
|
|
246
|
+
// `flows/read` still sees flow activity here, filtered by the Intel ACL alone. Narrowing it means
|
|
247
|
+
// filtering cards by capability inside the feed service, which is a change to the answer rather
|
|
248
|
+
// than to the door: #790.
|
|
231
249
|
//
|
|
232
250
|
// ⚠️ A SECOND door and not an option on `/audit`. That one is the contract `anchrd/signals` will
|
|
233
251
|
// build on, cursor and direction included; a shared signature would put both readers on one
|
|
@@ -736,6 +754,28 @@ export function createHttp(deps) {
|
|
|
736
754
|
});
|
|
737
755
|
return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
|
|
738
756
|
});
|
|
757
|
+
/**
|
|
758
|
+
* The same list across every flow (#774) — "did anything run", which used to take one call per
|
|
759
|
+
* flow and therefore went unasked.
|
|
760
|
+
*
|
|
761
|
+
* ⚠️ It sits at the root beside the other three run routes rather than under a flow, and the
|
|
762
|
+
* reason is the one already written for them in `packages/api/CLAUDE.md`: reading, cancelling and
|
|
763
|
+
* completing a run do not need the flow, and a run outlives it. Naming no flow is exactly that
|
|
764
|
+
* case. One tool answers both addresses — `flow_run_list` with `flowId: null` — which is the same
|
|
765
|
+
* many-to-one the attachment routes have.
|
|
766
|
+
*/
|
|
767
|
+
app.get("/flow-runs", async (context) => {
|
|
768
|
+
const auth = requireCapability(context, "flows", "read");
|
|
769
|
+
const url = new URL(context.req.url);
|
|
770
|
+
requireKnownQuery(url, ["failedOnly", "limit", "cursor"]);
|
|
771
|
+
const input = ListFlowRunsInput.parse({
|
|
772
|
+
flowId: null,
|
|
773
|
+
...(url.searchParams.has("failedOnly") ? { failedOnly: true } : {}),
|
|
774
|
+
...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}),
|
|
775
|
+
...(url.searchParams.has("cursor") ? { cursor: url.searchParams.get("cursor") } : {}),
|
|
776
|
+
});
|
|
777
|
+
return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
|
|
778
|
+
});
|
|
739
779
|
app.get("/flow-runs/:runId", async (context) => {
|
|
740
780
|
const auth = requireCapability(context, "flows", "read");
|
|
741
781
|
const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -158,15 +158,46 @@ export async function handleMcp(request, deps) {
|
|
|
158
158
|
email: deps.authorization.identity.email,
|
|
159
159
|
name: deps.authorization.identity.name ?? null,
|
|
160
160
|
}));
|
|
161
|
+
// The change journal, read forward from a stable position (#620). It is what makes Intel the
|
|
162
|
+
// simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
|
|
163
|
+
// and Intel never learns that the consumer exists. That is the D24 test passed rather than
|
|
164
|
+
// argued — remove every reader and nothing piles up here.
|
|
165
|
+
//
|
|
166
|
+
// ⚠️ **The capability follows the KIND asked for** (#774), which is why this tool stands outside
|
|
167
|
+
// both capability blocks rather than inside one. An audit row says the same thing its resource
|
|
168
|
+
// says, so the journal asks what reading that resource asks — `nodes/read` for `node`,
|
|
169
|
+
// `flows/read` for `flow` and `flow-run`. A capability of its own would be a second answer to one
|
|
170
|
+
// question and the two would drift; `nodes/read` for all three would drop the first of the three
|
|
171
|
+
// checks for exactly the two kinds #774 opened.
|
|
172
|
+
//
|
|
173
|
+
// ⚠️ **Offered on EITHER capability, decided per call.** Registration is per connection and the
|
|
174
|
+
// kind arrives per call, so the two cannot be the same gate. Holding one of the two is what makes
|
|
175
|
+
// the tool worth listing at all; which of the three journals it then answers is the handler's
|
|
176
|
+
// question. Same shape, same order, same refusal as `GET /audit` — one business layer, three thin
|
|
177
|
+
// surfaces.
|
|
178
|
+
if (permits(deps.authorization, "nodes", "read") ||
|
|
179
|
+
permits(deps.authorization, "flows", "read")) {
|
|
180
|
+
server.registerTool("audit_list", {
|
|
181
|
+
title: "List change events",
|
|
182
|
+
description: "Read the change journal forward from a stable position: what happened to the resources you may read, oldest first. `resourceType` picks which of the three journals to read — `node`, `flow` or `flow-run` — and each has its own cursor; do not carry one over to another. Pass `nextCursor` back as `after` to continue where you stopped — it says WHERE YOU GOT TO and comes back on the last page as well. Whether another page may follow is `hasMore`; a null `nextCursor` means nothing was returned, not that you have caught up. Events about permanently deleted nodes and flows are never returned, to anybody.",
|
|
183
|
+
inputSchema: AuditListRequest,
|
|
184
|
+
annotations: {
|
|
185
|
+
title: "List change events",
|
|
186
|
+
readOnlyHint: true,
|
|
187
|
+
destructiveHint: false,
|
|
188
|
+
idempotentHint: true,
|
|
189
|
+
openWorldHint: false,
|
|
190
|
+
},
|
|
191
|
+
}, async (input) => {
|
|
192
|
+
// ⚠️ Before the service, so a refusal costs no journal read. A 403 that arrived after the
|
|
193
|
+
// rows were read is a 403 that already read them.
|
|
194
|
+
if (!permits(deps.authorization, input.resourceType === "node" ? "nodes" : "flows", "read")) {
|
|
195
|
+
throw new IntelError(403, "permission_required", "Permission required");
|
|
196
|
+
}
|
|
197
|
+
return text(await deps.audit.list(actor, input));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
161
200
|
if (permits(deps.authorization, "nodes", "read")) {
|
|
162
|
-
// The change journal, read forward from a stable position (#620). It is what makes Intel the
|
|
163
|
-
// simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
|
|
164
|
-
// and Intel never learns that the consumer exists. That is the D24 test passed rather than
|
|
165
|
-
// argued — remove every reader and nothing piles up here.
|
|
166
|
-
//
|
|
167
|
-
// ⚠️ `nodes/read`, not a capability of its own. An audit row about a node says the same thing
|
|
168
|
-
// the node says; a second capability would be a second answer to one question, and the two
|
|
169
|
-
// would drift.
|
|
170
201
|
// The board as one answer (#648). Four tools, not five: a move is an update of `status` and
|
|
171
202
|
// `position`, and deleting a task is `node_archive` — nothing here removes a `nodes` row.
|
|
172
203
|
server.registerTool("board_get", {
|
|
@@ -218,7 +249,7 @@ export async function handleMcp(request, deps) {
|
|
|
218
249
|
}, async (input) => text(await deps.boards.resolveAssignees(actor, input)));
|
|
219
250
|
server.registerTool("board_task_create", {
|
|
220
251
|
title: "Add a card to a board",
|
|
221
|
-
description: "File a new card on a board. Omit the status to put it in the first column. Name a parent task to make it a subtask — it lands on the same board, and moving an existing card under another one is `node_update` with a new parent. The card is a node like any other: its own address, its own permissions, its own history.",
|
|
252
|
+
description: "File a new card on a board. Omit the status to put it in the first column. Name a parent task to make it a subtask — it lands on the same board, and moving an existing card under another one is `node_update` with a new parent. The card is a node like any other: its own address, its own permissions, its own history — and its own body. This call answers with the BOARD rather than the new card, so to write that body, make the card with `node_create` under the board instead: it answers with the node itself, files it on the board just the same, and hands you the id `node_version_create` asks for.",
|
|
222
253
|
inputSchema: BoardTaskCreateInput,
|
|
223
254
|
annotations: {
|
|
224
255
|
title: "Add a card to a board",
|
|
@@ -252,18 +283,6 @@ export async function handleMcp(request, deps) {
|
|
|
252
283
|
openWorldHint: false,
|
|
253
284
|
},
|
|
254
285
|
}, async (input) => text(await deps.boards.update(actor, input)));
|
|
255
|
-
server.registerTool("audit_list", {
|
|
256
|
-
title: "List change events",
|
|
257
|
-
description: "Read the change journal forward from a stable position: what happened to the nodes you may read, oldest first. Pass `nextCursor` back as `after` to continue where you stopped; a null `nextCursor` means you have caught up. Only node events can be listed — flow events need their own visibility check and are refused rather than left out. Events about permanently deleted nodes are never returned, to anybody.",
|
|
258
|
-
inputSchema: AuditListRequest,
|
|
259
|
-
annotations: {
|
|
260
|
-
title: "List change events",
|
|
261
|
-
readOnlyHint: true,
|
|
262
|
-
destructiveHint: false,
|
|
263
|
-
idempotentHint: true,
|
|
264
|
-
openWorldHint: false,
|
|
265
|
-
},
|
|
266
|
-
}, async (input) => text(await deps.audit.list(actor, input)));
|
|
267
286
|
server.registerTool("feed_list", {
|
|
268
287
|
title: "List recent activity",
|
|
269
288
|
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.",
|
|
@@ -539,7 +558,7 @@ export async function handleMcp(request, deps) {
|
|
|
539
558
|
if (permits(deps.authorization, "nodes", "create")) {
|
|
540
559
|
server.registerTool("node_create", {
|
|
541
560
|
title: "Create node",
|
|
542
|
-
description: "Create a governed folder, document,
|
|
561
|
+
description: "Create a governed node: a folder, a board, a document, a card, an attachment or a table. A card made under a board lands on that board, and the answer carries the new node's id — which is what `node_version_create` needs to give it a body.",
|
|
543
562
|
inputSchema: CreateNodeInput,
|
|
544
563
|
annotations: {
|
|
545
564
|
title: "Create node",
|
|
@@ -553,7 +572,7 @@ export async function handleMcp(request, deps) {
|
|
|
553
572
|
if (permits(deps.authorization, "nodes", "write")) {
|
|
554
573
|
server.registerTool("node_version_create", {
|
|
555
574
|
title: "Create node version",
|
|
556
|
-
description: "Append an immutable content version using an optimistic base version.",
|
|
575
|
+
description: "Append an immutable content version using an optimistic base version. Documents and board cards both take one — writing a card here is how a ticket gets a body instead of only a title.",
|
|
557
576
|
inputSchema: SaveNodeVersionInput,
|
|
558
577
|
annotations: {
|
|
559
578
|
title: "Create node version",
|
|
@@ -929,7 +948,7 @@ export async function handleMcp(request, deps) {
|
|
|
929
948
|
// whoever started it, and only their own runs are the calling user's to read out that way.
|
|
930
949
|
server.registerTool("flow_run_list", {
|
|
931
950
|
title: "List flow runs",
|
|
932
|
-
description:
|
|
951
|
+
description: 'List flow runs, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Name a `flowId` for one flow, or leave it out for the runs of every flow you may read — which is the way to answer "did anything run at all" without knowing a flow first. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.',
|
|
933
952
|
inputSchema: ListFlowRunsInput,
|
|
934
953
|
annotations: {
|
|
935
954
|
title: "List flow runs",
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -27,6 +27,22 @@ const nothingWithheld = { titles: [], hidden: 0 };
|
|
|
27
27
|
function applicableVerbs(kind) {
|
|
28
28
|
return kind === "folder" ? ["read", "write", "execute", "share"] : ["read", "write", "share"];
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Which kinds hold an editor body — the text somebody types and `node_get` reads back.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ **A card is one of them, and that is the whole of D66 on the write side** (#785). A task is a
|
|
34
|
+
* node like every other: its text is a node version, in the same table, through the same route. The
|
|
35
|
+
* panel reaches this call through `POST /nodes/:id/versions` exactly as MCP does, so a list that
|
|
36
|
+
* omitted `task` closed BOTH surfaces at once — an agent could name a card and not describe it, and
|
|
37
|
+
* a reader typing into the card got "could not be saved" over a body the model says it should hold.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ **A set, not one more `||`.** The other four kinds each carry their content some other way —
|
|
40
|
+
* a folder and a board hold children, an attachment holds bytes, a table holds CSV segments — and
|
|
41
|
+
* naming the ones that DO take an editor version is what keeps the next kind from being waved
|
|
42
|
+
* through by a condition that only ever grew. The annotation ties it to the enum: a kind renamed in
|
|
43
|
+
* the contract stops this file from compiling instead of silently dropping out of the set.
|
|
44
|
+
*/
|
|
45
|
+
const EditorContentKinds = ["document", "task"];
|
|
30
46
|
// ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
|
|
31
47
|
// `share` on one folder must not learn the titles of flows they may not see, so the ones they may
|
|
32
48
|
// see are named and the rest are only counted (ADR-0004 §3, and #17's review) — which is the whole
|
|
@@ -666,8 +682,8 @@ export function createNodes(deps) {
|
|
|
666
682
|
return document;
|
|
667
683
|
}
|
|
668
684
|
const node = await requireVisible(actor, input.nodeId);
|
|
669
|
-
if (node.kind
|
|
670
|
-
throw new IntelError(409, "document_content_required", "Only documents accept editor content versions");
|
|
685
|
+
if (!EditorContentKinds.includes(node.kind)) {
|
|
686
|
+
throw new IntelError(409, "document_content_required", "Only documents and cards accept editor content versions");
|
|
671
687
|
}
|
|
672
688
|
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
673
689
|
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- `flow_run_list` may now be asked without a flow (#774): "did anything run at all", over every
|
|
2
|
+
-- flow the caller may read. `flow_runs_flow_idx (flow_id, created_at DESC)` cannot serve that — it
|
|
3
|
+
-- leads with `flow_id`, and without an equality on that column the ordering falls off the index and
|
|
4
|
+
-- SQLite sorts the whole table instead.
|
|
5
|
+
--
|
|
6
|
+
-- ⚠️ Measured against the real `flowRunsPageQuery({ scoped: false })` before this file existed, on
|
|
7
|
+
-- the same engine and the same schema:
|
|
8
|
+
--
|
|
9
|
+
-- SCAN run
|
|
10
|
+
-- … | USE TEMP B-TREE FOR ORDER BY
|
|
11
|
+
--
|
|
12
|
+
-- That last line is a sorting pass over every run in the installation. It costs nothing while a
|
|
13
|
+
-- customer has fifty runs and everything once an agent has been running for a month.
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` and `0026` give:
|
|
16
|
+
-- two runs created in the same millisecond are not exotic, and without the tie-break on `id` a
|
|
17
|
+
-- reader continuing on `created_at` alone skips the second one with no error and no log.
|
|
18
|
+
--
|
|
19
|
+
-- ⚠️ `DESC` on both, because this page is newest-first. An ASC index can be walked backwards by
|
|
20
|
+
-- SQLite, but only as long as EVERY term agrees on the direction — matching the `ORDER BY` exactly
|
|
21
|
+
-- is the one shape that keeps that true when a third term is added later.
|
|
22
|
+
--
|
|
23
|
+
-- ⚠️ This does NOT replace `flow_runs_flow_idx`. That one still serves the scoped page — the runs
|
|
24
|
+
-- of ONE flow — which is the older and by far the more frequent question; dropping it would put a
|
|
25
|
+
-- full scan into the call that has none today. Two questions, two shapes, two indexes.
|
|
26
|
+
CREATE INDEX flow_runs_time_idx
|
|
27
|
+
ON flow_runs(created_at DESC, id DESC);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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.26.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.32.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|