@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.
@@ -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
- resourceType: "node",
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 listNodeEvents(actor, query) {
126
+ async listEvents(actor, query) {
72
127
  const after = query.after ?? null;
73
- const bindings = [
74
- ...subtreeBindings(actor, "read", deps.now().toISOString()),
75
- after === null ? null : after.occurredAt,
76
- after === null ? null : after.occurredAt,
77
- after === null ? null : after.occurredAt,
78
- after === null ? null : after.id,
79
- query.limit,
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, feedKindOf, } from "@anchrd/intel-contract/feed";
1
+ import { FeedAction, feedResourceTypeOf, } from "@anchrd/intel-contract/feed";
2
2
  import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
3
- // The two kinds of resource the feed reads, split out of the one action list so the SQL can name
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) => feedKindOf(action) === "node");
7
- const FLOW_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "flow");
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 two kinds at once, and the older index leads with `resource_type`; under
60
- * `IN ('node', 'flow')` — or, as written here, under two branches joined by `OR` — that column
61
- * stops being an equality prefix, and the ordering falls off it. Measured before `0026` existed:
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 (two values out of three);
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 two halves ask two DIFFERENT authorization questions, and neither may stand in for the
73
- * other.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
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. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
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, e.resource_id,
93
- COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) AS resource_title,
94
- COALESCE(n.parent_id, flow.parent_id, json_extract(e.metadata_json, '$.parentId'))
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), 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)
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 = ?, updated_at = ?
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
- .bind(input.versionId, input.occurredAt, input.flowId, input.versionId, input.flowId),
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
- return ((await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null);
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 page = input.cursor
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(`${subtreeCte}
1001
- SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
1002
- JOIN flows flow ON flow.id = run.flow_id
1003
- WHERE run.flow_id = ? AND ${runVisible}
1004
- ${input.failedOnly ? "AND run.status = 'failed'" : ""}
1005
- ${page.clause}
1006
- ORDER BY run.created_at DESC, run.id DESC
1007
- LIMIT ?`)
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
- export declare function flowInSubtreeOver(parentExpression: string): string;
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
- export function flowInSubtreeOver(parentExpression) {
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 flow.owner_id = ?
261
+ OR ${flowAlias}.owner_id = ?
249
262
  OR ${parentExpression} IN (SELECT id FROM allowed)
250
- OR ${flowGrantExists("flow.id")}
263
+ OR ${flowGrantExists(`${flowAlias}.id`)}
251
264
  )`;
252
265
  }
253
266
  export const flowInSubtree = flowInSubtreeOver("flow.parent_id");