@anchrd/intel-api 0.39.0 → 0.41.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 +16 -0
- 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 +119 -23
- package/dist/adapters/db/db-grants.d.ts +14 -1
- package/dist/adapters/db/db-grants.js +16 -3
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.js +58 -6
- package/dist/audit/audit.js +27 -12
- package/dist/audit/audit.types.d.ts +3 -2
- package/dist/bundle/bundle.js +12 -0
- package/dist/flows/flows.js +29 -2
- package/dist/flows/flows.types.d.ts +16 -2
- package/dist/http/http.js +46 -6
- package/dist/indexing/indexing.js +9 -10
- package/dist/intel/intel.js +1 -0
- package/dist/intel/intel.types.d.ts +2 -0
- package/dist/mcp/mcp.js +90 -21
- package/dist/mcp/mcp.types.d.ts +2 -0
- package/dist/nodes/nodes.js +38 -0
- package/dist/nodes/nodes.types.d.ts +8 -1
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +2 -2
|
@@ -10,6 +10,7 @@ import { createFlows } from "../../flows/flows.js";
|
|
|
10
10
|
import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
|
|
11
11
|
import { createIntel } from "../../intel/intel.js";
|
|
12
12
|
import { createNodes } from "../../nodes/nodes.js";
|
|
13
|
+
import { createPrompts } from "../../prompts/prompts.js";
|
|
13
14
|
import { bearer } from "../../shared/gate-authorization/gate-authorization.js";
|
|
14
15
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
15
16
|
import { sha256Hex } from "../../shared/sha256/sha256.js";
|
|
@@ -22,6 +23,7 @@ import { createFeedRepository } from "../db/db-feed.js";
|
|
|
22
23
|
import { createFlowRepository } from "../db/db-flows.js";
|
|
23
24
|
import { createNodeIndexRepository } from "../db/db-indexing.js";
|
|
24
25
|
import { createOAuthClientStore } from "../db/db-oauth.js";
|
|
26
|
+
import { createPromptRepository } from "../db/db-prompts.js";
|
|
25
27
|
import { createDocumentConverter } from "../document-converter/document-converter.js";
|
|
26
28
|
import { createFlowRuntime } from "../flow-runtime/flow-runtime.js";
|
|
27
29
|
import { createIndexQueue, IndexMessage } from "../index-queue/index-queue.js";
|
|
@@ -265,6 +267,20 @@ export default {
|
|
|
265
267
|
tools,
|
|
266
268
|
audit: createAudit({ audit: auditRepository }),
|
|
267
269
|
feed: createFeed({ feed: feedRepository }),
|
|
270
|
+
/**
|
|
271
|
+
* The slash-command catalogue (#775). It reads its own rows through its own repository and
|
|
272
|
+
* borrows exactly one thing from the node service: reading one document, with the
|
|
273
|
+
* authorization walk that service already performs. A second reader into R2 here would be a
|
|
274
|
+
* second place for that ACL to be applied, and the one that gets it wrong is the one nobody
|
|
275
|
+
* looks at.
|
|
276
|
+
*/
|
|
277
|
+
prompts: createPrompts({
|
|
278
|
+
repository: createPromptRepository({ db: env.DB, now }),
|
|
279
|
+
// ⚠️ Handed on whole, and nothing is decided here. Turning a stored version into readable
|
|
280
|
+
// text is business behaviour, and this shell maps bindings — it does not get to know what a
|
|
281
|
+
// BlockNote payload is.
|
|
282
|
+
readDocument: async (actor, nodeId) => await nodes.get(actor, nodeId),
|
|
283
|
+
}),
|
|
268
284
|
boards: createBoards({
|
|
269
285
|
boards: boardRepository,
|
|
270
286
|
nodes,
|
|
@@ -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;
|
|
@@ -2,6 +2,7 @@ import { Flow, FlowGraph, FlowVersion, } from "@anchrd/intel-contract/flow";
|
|
|
2
2
|
import { FlowRun } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
|
|
4
4
|
import { flowCallable, flowCallableBindings, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, grantColumns, grantInForce, mapGrant, principalColumns, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
5
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
5
6
|
const flowColumnNames = [
|
|
6
7
|
"id",
|
|
7
8
|
"parent_id",
|
|
@@ -13,6 +14,7 @@ const flowColumnNames = [
|
|
|
13
14
|
"created_at",
|
|
14
15
|
"updated_at",
|
|
15
16
|
"archived_at",
|
|
17
|
+
"prompt_name",
|
|
16
18
|
];
|
|
17
19
|
const flowColumns = flowColumnNames.join(", ");
|
|
18
20
|
const runColumnNames = [
|
|
@@ -53,6 +55,7 @@ function mapFlow(row) {
|
|
|
53
55
|
createdAt: row.created_at,
|
|
54
56
|
updatedAt: row.updated_at,
|
|
55
57
|
archivedAt: row.archived_at,
|
|
58
|
+
promptName: row.prompt_name,
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
function mapVersion(row) {
|
|
@@ -74,6 +77,47 @@ function mapVersion(row) {
|
|
|
74
77
|
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's. The bindings
|
|
75
78
|
// live in the factory below because `flowInSubtree` now needs the moment expiry is measured against.
|
|
76
79
|
const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
|
|
80
|
+
/**
|
|
81
|
+
* One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
|
|
82
|
+
* (#774, found in the review of #787).
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
|
|
85
|
+
* naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
|
|
86
|
+
* it never sees the value — so an `OR` on the leading index column drops that column as an equality
|
|
87
|
+
* prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
|
|
88
|
+
* engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
|
|
89
|
+
*
|
|
90
|
+
* -- WHERE run.flow_id = ?
|
|
91
|
+
* SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
|
|
92
|
+
*
|
|
93
|
+
* -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
|
|
94
|
+
* SCAN run | USE TEMP B-TREE FOR ORDER BY
|
|
95
|
+
*
|
|
96
|
+
* The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
|
|
97
|
+
* along and is the frequent call, and it would have started scanning every run in the installation
|
|
98
|
+
* without a single test going red. `db-audit.ts` had the same choice one file over and split its
|
|
99
|
+
* statements per kind; this is that decision, applied where the review found it missing.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
|
|
102
|
+
* whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
|
|
103
|
+
* id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
|
|
104
|
+
* ordering has to come from somewhere.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
|
|
107
|
+
* they were already written this way. An absent clause binds nothing, so every caller of this
|
|
108
|
+
* function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
|
|
109
|
+
* id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
|
|
110
|
+
*/
|
|
111
|
+
export function flowRunsPageQuery(shape) {
|
|
112
|
+
return `${subtreeCte}
|
|
113
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
114
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
115
|
+
WHERE ${shape.scoped ? "run.flow_id = ? AND " : ""}${runVisible}
|
|
116
|
+
${shape.failedOnly ? "AND run.status = 'failed'" : ""}
|
|
117
|
+
${shape.paged ? "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))" : ""}
|
|
118
|
+
ORDER BY run.created_at DESC, run.id DESC
|
|
119
|
+
LIMIT ?`;
|
|
120
|
+
}
|
|
77
121
|
function mapStep(row) {
|
|
78
122
|
return {
|
|
79
123
|
runId: row.run_id,
|
|
@@ -863,23 +907,61 @@ export function createFlowRepository(deps) {
|
|
|
863
907
|
.first();
|
|
864
908
|
return stored ? "saved" : "conflict";
|
|
865
909
|
},
|
|
910
|
+
async promptNameHolder(actor, name) {
|
|
911
|
+
// One question, one implementation, shared with the node repository — see `db-prompts.ts`.
|
|
912
|
+
return await promptNameHolder(deps, actor, name);
|
|
913
|
+
},
|
|
914
|
+
/**
|
|
915
|
+
* ⚠️ **Two statement forms, not one with a conditional value** (#775). Publishing without
|
|
916
|
+
* mentioning a prompt name must leave the name alone; `SET prompt_name = ?` with the current
|
|
917
|
+
* value read back beforehand would work until two publishes race, and then the second one
|
|
918
|
+
* writes a name the first had just replaced. The form that does not mention the column cannot
|
|
919
|
+
* do that, and the form that does carries its guard.
|
|
920
|
+
*
|
|
921
|
+
* ⚠️ **The guard is on all three statements** (`destructive.md`, `anchrd/intel#457`). A
|
|
922
|
+
* statement matching zero rows is not an error, so an idempotency row and an audit row written
|
|
923
|
+
* without the same condition would book a publication that never happened — and the caller's
|
|
924
|
+
* retry would then be answered out of the replay table.
|
|
925
|
+
*/
|
|
866
926
|
async publish(input) {
|
|
927
|
+
const setsName = input.promptName !== undefined;
|
|
928
|
+
const nameAssignment = setsName ? ", prompt_name = ?" : "";
|
|
929
|
+
const nameBinding = setsName ? [input.promptName] : [];
|
|
930
|
+
// ⚠️ `other.id <> ?` lets a flow keep the name it already holds: republishing an offered flow
|
|
931
|
+
// sends the same name again, and without the exclusion it would collide with itself.
|
|
932
|
+
const nameGuard = setsName
|
|
933
|
+
? `AND (
|
|
934
|
+
? IS NULL
|
|
935
|
+
OR (
|
|
936
|
+
NOT EXISTS (SELECT 1 FROM nodes holder WHERE holder.prompt_name = ?)
|
|
937
|
+
AND NOT EXISTS (
|
|
938
|
+
SELECT 1 FROM flows other WHERE other.prompt_name = ? AND other.id <> ?
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
)`
|
|
942
|
+
: "";
|
|
943
|
+
const nameGuardBindings = setsName
|
|
944
|
+
? [input.promptName, input.promptName, input.promptName, input.flowId]
|
|
945
|
+
: [];
|
|
946
|
+
const nameEcho = setsName ? "AND prompt_name IS ?" : "";
|
|
947
|
+
const nameEchoBinding = setsName ? [input.promptName] : [];
|
|
867
948
|
try {
|
|
868
949
|
await deps.db.batch([
|
|
869
950
|
deps.db
|
|
870
|
-
.prepare(`UPDATE flows SET published_version_id =
|
|
951
|
+
.prepare(`UPDATE flows SET published_version_id = ?${nameAssignment}, updated_at = ?
|
|
871
952
|
WHERE id = ? AND EXISTS (
|
|
872
953
|
SELECT 1 FROM flow_versions WHERE id = ? AND flow_id = ?
|
|
873
|
-
)
|
|
874
|
-
|
|
954
|
+
)
|
|
955
|
+
${nameGuard}`)
|
|
956
|
+
.bind(input.versionId, ...nameBinding, input.occurredAt, input.flowId, input.versionId, input.flowId, ...nameGuardBindings),
|
|
875
957
|
deps.db
|
|
876
958
|
.prepare(`INSERT INTO idempotency_keys (
|
|
877
959
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
878
960
|
) SELECT ?, 'flows.publish', ?, ?, ?
|
|
879
961
|
WHERE EXISTS (
|
|
880
|
-
SELECT 1 FROM flows WHERE id = ? AND published_version_id = ?
|
|
962
|
+
SELECT 1 FROM flows WHERE id = ? AND published_version_id = ? ${nameEcho}
|
|
881
963
|
)`)
|
|
882
|
-
.bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId),
|
|
964
|
+
.bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId, ...nameEchoBinding),
|
|
883
965
|
deps.db
|
|
884
966
|
.prepare(`INSERT INTO audit_events (
|
|
885
967
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -896,7 +978,22 @@ export function createFlowRepository(deps) {
|
|
|
896
978
|
if (!replayed)
|
|
897
979
|
throw error;
|
|
898
980
|
}
|
|
899
|
-
|
|
981
|
+
const published = (await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null;
|
|
982
|
+
// ⚠️ Which of the two refusals it was, asked only after the write has failed — the same order
|
|
983
|
+
// `updateNode` uses, and for the same reason: deciding first and writing unguarded is the
|
|
984
|
+
// shape that loses the race the guard exists for.
|
|
985
|
+
if (!published && setsName && input.promptName !== null) {
|
|
986
|
+
const taken = await deps.db
|
|
987
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ?
|
|
988
|
+
UNION ALL
|
|
989
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ? AND id <> ?
|
|
990
|
+
LIMIT 1`)
|
|
991
|
+
.bind(input.promptName, input.promptName, input.flowId)
|
|
992
|
+
.first();
|
|
993
|
+
if (taken?.found === 1)
|
|
994
|
+
return "prompt_name_taken";
|
|
995
|
+
}
|
|
996
|
+
return published;
|
|
900
997
|
},
|
|
901
998
|
// The mirror of `publish`, with the same shape of guards: the idempotency row is written only
|
|
902
999
|
// if the UPDATE actually withdrew something, so revoking what was never published leaves no key
|
|
@@ -983,29 +1080,28 @@ export function createFlowRepository(deps) {
|
|
|
983
1080
|
.first();
|
|
984
1081
|
return row ? mapRun(row) : null;
|
|
985
1082
|
},
|
|
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.
|
|
1083
|
+
// Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving.
|
|
988
1084
|
//
|
|
989
1085
|
// ⚠️ `runVisible`, the same predicate `getRunVisible` uses one function up. Seeing the flow is
|
|
990
1086
|
// not the same as seeing its runs: a library flow is executable for everyone and readable by
|
|
991
1087
|
// nobody, and the people running it must still find their own runs back.
|
|
1088
|
+
//
|
|
1089
|
+
// ⚠️ A null flow means EVERY flow this actor may read, not "no flow" (#774). The row filter is
|
|
1090
|
+
// untouched by it — `runVisible` decides per row either way — so the wider question cannot
|
|
1091
|
+
// answer with a run the narrow one would have hidden. Which SHAPE of statement asks it is
|
|
1092
|
+
// `flowRunsPageQuery`'s business, and the reason it is a shape rather than a binding is written
|
|
1093
|
+
// there.
|
|
992
1094
|
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: [] };
|
|
1095
|
+
const scoped = input.flowId !== null;
|
|
999
1096
|
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)
|
|
1097
|
+
.prepare(flowRunsPageQuery({
|
|
1098
|
+
scoped,
|
|
1099
|
+
failedOnly: input.failedOnly,
|
|
1100
|
+
paged: input.cursor !== null && input.cursor !== undefined,
|
|
1101
|
+
}))
|
|
1102
|
+
.bind(...readableBindings(actor), ...(scoped ? [input.flowId] : []), ...runVisibleBindings(actor), ...(input.cursor
|
|
1103
|
+
? [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id]
|
|
1104
|
+
: []), input.limit)
|
|
1009
1105
|
.all();
|
|
1010
1106
|
return (result.results ?? []).map(mapRun);
|
|
1011
1107
|
},
|
|
@@ -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");
|