@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Actor } from "../../nodes/nodes.types.js";
|
|
2
|
+
import type { PromptNameHolder, PromptRepository } from "../../prompts/prompts.types.js";
|
|
3
|
+
import type { D1Database } from "./db.types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The documents this actor may read that are offered as a slash command (#775).
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
|
|
8
|
+
* is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
|
|
9
|
+
* predicate over different tables and needs its own bindings in its own order. Welded into one
|
|
10
|
+
* statement the two would share a placeholder sequence that nobody can read back — and the failure
|
|
11
|
+
* that produces is a shifted binding, which does not throw: it answers the wrong list.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
|
|
14
|
+
* a command that starts an archived document is a command onto something the tree no longer shows.
|
|
15
|
+
* The name stays taken while it is archived — a restore brings the command back rather than finding
|
|
16
|
+
* its name given away in the meantime.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
|
|
19
|
+
* one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
|
|
20
|
+
* partial index that only holds the offered rows.
|
|
21
|
+
*/
|
|
22
|
+
export declare const offeredDocumentsQuery: string;
|
|
23
|
+
/**
|
|
24
|
+
* The published flows this actor may read that are offered as a slash command.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
|
|
27
|
+
* Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
|
|
28
|
+
* name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
|
|
29
|
+
* the name is what makes republishing it the same command rather than a new one, and a client that
|
|
30
|
+
* stored it is not left pointing at a command that has since become somebody else's.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
|
|
33
|
+
* people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
|
|
34
|
+
* carries a title and a description, and those are the readable half. Somebody who may only run it
|
|
35
|
+
* still can, through `flow_run_start` with the id they were given; what they do not get is a
|
|
36
|
+
* listing that names it.
|
|
37
|
+
*/
|
|
38
|
+
export declare const offeredFlowsQuery: string;
|
|
39
|
+
export declare function promptNameHolder(deps: {
|
|
40
|
+
db: D1Database;
|
|
41
|
+
now(): Date;
|
|
42
|
+
}, actor: Actor, name: string): Promise<PromptNameHolder | null>;
|
|
43
|
+
export declare function createPromptRepository(deps: {
|
|
44
|
+
db: D1Database;
|
|
45
|
+
now(): Date;
|
|
46
|
+
}): PromptRepository;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { flowInSubtree, flowInSubtreeBindings, subtreeBindings, subtreeCte } from "./db-grants.js";
|
|
2
|
+
/**
|
|
3
|
+
* The documents this actor may read that are offered as a slash command (#775).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **Two statements rather than one `UNION`, and the reason is the visibility walk.** A document
|
|
6
|
+
* is visible through `allowed`; a flow is visible through `flowInSubtree`, which is a different
|
|
7
|
+
* predicate over different tables and needs its own bindings in its own order. Welded into one
|
|
8
|
+
* statement the two would share a placeholder sequence that nobody can read back — and the failure
|
|
9
|
+
* that produces is a shifted binding, which does not throw: it answers the wrong list.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **`archived_at IS NULL` is part of being offered.** Archiving is what "delete" means here, and
|
|
12
|
+
* a command that starts an archived document is a command onto something the tree no longer shows.
|
|
13
|
+
* The name stays taken while it is archived — a restore brings the command back rather than finding
|
|
14
|
+
* its name given away in the meantime.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ The `kind = 'document'` is redundant against the CHECK in `0028` and stays anyway: it is the
|
|
17
|
+
* one line that keeps this query honest if that CHECK is ever loosened, and it costs nothing on a
|
|
18
|
+
* partial index that only holds the offered rows.
|
|
19
|
+
*/
|
|
20
|
+
export const offeredDocumentsQuery = `${subtreeCte}
|
|
21
|
+
SELECT n.prompt_name, n.id, n.title, n.description
|
|
22
|
+
FROM nodes n
|
|
23
|
+
JOIN allowed ON allowed.id = n.id
|
|
24
|
+
WHERE n.prompt_name IS NOT NULL
|
|
25
|
+
AND n.kind = 'document'
|
|
26
|
+
AND n.archived_at IS NULL
|
|
27
|
+
ORDER BY n.prompt_name`;
|
|
28
|
+
/**
|
|
29
|
+
* The published flows this actor may read that are offered as a slash command.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ **`published_version_id IS NOT NULL` is the switch, and it is the whole of it** (#775).
|
|
32
|
+
* Publishing is already the decision that a flow is for other people, so `flow_publish` sets the
|
|
33
|
+
* name and nothing else asks again. A flow that is withdrawn KEEPS its name and leaves this list:
|
|
34
|
+
* the name is what makes republishing it the same command rather than a new one, and a client that
|
|
35
|
+
* stored it is not left pointing at a command that has since become somebody else's.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ `read`, not `execute`, and that is not the obvious choice. A flow in the library — runnable by
|
|
38
|
+
* people who may not open it (ADR-0004 §2/§3) — is deliberately NOT listed: a catalogue entry
|
|
39
|
+
* carries a title and a description, and those are the readable half. Somebody who may only run it
|
|
40
|
+
* still can, through `flow_run_start` with the id they were given; what they do not get is a
|
|
41
|
+
* listing that names it.
|
|
42
|
+
*/
|
|
43
|
+
export const offeredFlowsQuery = `${subtreeCte}
|
|
44
|
+
SELECT flow.prompt_name, flow.id, flow.title, flow.description
|
|
45
|
+
FROM flows flow
|
|
46
|
+
WHERE flow.prompt_name IS NOT NULL
|
|
47
|
+
AND flow.published_version_id IS NOT NULL
|
|
48
|
+
AND flow.archived_at IS NULL
|
|
49
|
+
AND ${flowInSubtree}
|
|
50
|
+
ORDER BY flow.prompt_name`;
|
|
51
|
+
/**
|
|
52
|
+
* Who holds one name, asked of both tables at once, for the refusal a taken name gets.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ **Existence is answered to everybody; the TITLE only to those entitled to it.** The question
|
|
55
|
+
* "is this name free" has to be answered truthfully or the caller can do nothing — they are refused
|
|
56
|
+
* by an index they cannot see and told the node was changed by another editor. What must not travel
|
|
57
|
+
* is the title: naming the document that holds `payroll` tells somebody without access that a
|
|
58
|
+
* document by that title exists, and ADR-0004 §3 is explicit that a refusal must not become a way
|
|
59
|
+
* of reading the tree. The `CASE` below is where the two halves part.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ **One function, called from both repositories, rather than one query per repository.** A node
|
|
62
|
+
* write and a flow publish refuse for the same reason and have to say the same sentence; two
|
|
63
|
+
* spellings of this would drift, and the one that drifts is the one whose surface is used less.
|
|
64
|
+
* `destructive.md` calls it by name: two queries with similar names are two different questions.
|
|
65
|
+
*/
|
|
66
|
+
const holderQuery = `${subtreeCte}
|
|
67
|
+
SELECT 'document' AS kind, CASE WHEN allowed.id IS NULL THEN NULL ELSE n.title END AS title
|
|
68
|
+
FROM nodes n
|
|
69
|
+
LEFT JOIN allowed ON allowed.id = n.id
|
|
70
|
+
WHERE n.prompt_name = ?
|
|
71
|
+
UNION ALL
|
|
72
|
+
SELECT 'flow' AS kind, CASE WHEN ${flowInSubtree} THEN flow.title ELSE NULL END AS title
|
|
73
|
+
FROM flows flow
|
|
74
|
+
WHERE flow.prompt_name = ?`;
|
|
75
|
+
export async function promptNameHolder(deps, actor, name) {
|
|
76
|
+
const now = deps.now().toISOString();
|
|
77
|
+
const row = await deps.db
|
|
78
|
+
.prepare(holderQuery)
|
|
79
|
+
.bind(...subtreeBindings(actor, "read", now), name, ...flowInSubtreeBindings(actor, "read", now), name)
|
|
80
|
+
.first();
|
|
81
|
+
return row === null ? null : { kind: row.kind, title: row.title };
|
|
82
|
+
}
|
|
83
|
+
function mapEntry(row, kind) {
|
|
84
|
+
return {
|
|
85
|
+
name: row.prompt_name,
|
|
86
|
+
kind,
|
|
87
|
+
targetId: row.id,
|
|
88
|
+
title: row.title,
|
|
89
|
+
description: row.description,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function createPromptRepository(deps) {
|
|
93
|
+
return {
|
|
94
|
+
async listOffered(actor) {
|
|
95
|
+
const now = deps.now().toISOString();
|
|
96
|
+
/**
|
|
97
|
+
* ⚠️ **A half the actor has no capability for is not asked for at all** (#775, following
|
|
98
|
+
* #774). Running the statement and discarding its rows would be the same answer at the cost
|
|
99
|
+
* of a walk over the tree — and worse, it would put the decision in the mapping step, where
|
|
100
|
+
* the next reader of this function cannot see it.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ **Each statement carries its own kind, rather than the two being read back out of fixed
|
|
103
|
+
* positions.** With only `flows/read` the flow query is the ONLY element, so it arrives as
|
|
104
|
+
* result zero: a `const [documents, flows]` here puts the flows in a variable called
|
|
105
|
+
* `documents`, and the name is then a lie in one of the four branches. It answers correctly
|
|
106
|
+
* today and mis-binds the moment somebody adds a third conditional query — which is exactly
|
|
107
|
+
* the kind of edit this list invites.
|
|
108
|
+
*/
|
|
109
|
+
const asked = [];
|
|
110
|
+
if (actor.canReadNodes) {
|
|
111
|
+
asked.push({
|
|
112
|
+
kind: "document",
|
|
113
|
+
statement: deps.db
|
|
114
|
+
.prepare(offeredDocumentsQuery)
|
|
115
|
+
.bind(...subtreeBindings(actor, "read", now)),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (actor.canReadFlows) {
|
|
119
|
+
asked.push({
|
|
120
|
+
kind: "flow",
|
|
121
|
+
statement: deps.db
|
|
122
|
+
.prepare(offeredFlowsQuery)
|
|
123
|
+
.bind(...subtreeBindings(actor, "read", now), ...flowInSubtreeBindings(actor, "read", now)),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const results = await deps.db.batch(asked.map((each) => each.statement));
|
|
127
|
+
return (asked
|
|
128
|
+
.flatMap((each, index) => (results[index]?.results ?? []).map((row) => mapEntry(row, each.kind)))
|
|
129
|
+
// ⚠️ Sorted over the MERGED list rather than relying on the two `ORDER BY` clauses. Each
|
|
130
|
+
// statement orders its own half, and concatenating two ordered halves gives an unordered
|
|
131
|
+
// whole — `prompts/list` would then be alphabetical within documents and again within
|
|
132
|
+
// flows, which reads as "unsorted with a pattern" to anybody scrolling it.
|
|
133
|
+
.sort((left, right) => left.name.localeCompare(right.name)));
|
|
134
|
+
},
|
|
135
|
+
async findOffered(actor, name) {
|
|
136
|
+
// The catalogue is the list this actor may see, so looking one entry up is looking in that
|
|
137
|
+
// list. A separate statement with its own `WHERE prompt_name = ?` would be a second walk to
|
|
138
|
+
// keep in step with the first, for a set that is small by construction: the offered rows are
|
|
139
|
+
// a curated handful, not the tree.
|
|
140
|
+
const offered = await this.listOffered(actor);
|
|
141
|
+
return offered.find((entry) => entry.name === name) ?? null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { descendantsCte, flowInSubtree, flowInSubtreeBindings, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
2
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
2
3
|
const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
|
|
3
4
|
size, segment, created_by, created_at`;
|
|
4
5
|
const grantColumns = grantColumnsFor("node_id");
|
|
@@ -17,7 +18,7 @@ function dedupedByNode(rows) {
|
|
|
17
18
|
});
|
|
18
19
|
}
|
|
19
20
|
const nodeColumns = `n.id, n.parent_id, n.kind, n.title, n.description,
|
|
20
|
-
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at`;
|
|
21
|
+
n.owner_id, n.current_version_id, n.created_at, n.updated_at, n.archived_at, n.prompt_name`;
|
|
21
22
|
function mapNode(row) {
|
|
22
23
|
return {
|
|
23
24
|
id: row.id,
|
|
@@ -30,6 +31,7 @@ function mapNode(row) {
|
|
|
30
31
|
createdAt: row.created_at,
|
|
31
32
|
updatedAt: row.updated_at,
|
|
32
33
|
archivedAt: row.archived_at,
|
|
34
|
+
promptName: row.prompt_name,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
37
|
function mapVersion(row) {
|
|
@@ -529,6 +531,22 @@ export function createNodeRepository(deps) {
|
|
|
529
531
|
.all();
|
|
530
532
|
return (result.results ?? []).map(mapVersion);
|
|
531
533
|
},
|
|
534
|
+
/**
|
|
535
|
+
* ⚠️ **The prompt name is the one field here whose write can fail for a reason that is not a
|
|
536
|
+
* conflict, and its guard sits ON the statement rather than in front of it** (#775). A
|
|
537
|
+
* `SELECT` beforehand decides on a state that may be gone by the time the `UPDATE` lands; the
|
|
538
|
+
* condition below is evaluated inside the same transaction as the write it guards.
|
|
539
|
+
*
|
|
540
|
+
* ⚠️ **And it is on every statement of the batch, not only the first** — `destructive.md`,
|
|
541
|
+
* `anchrd/intel#457`. A statement matching zero rows is not an error and the batch commits
|
|
542
|
+
* anyway, so the idempotency row and the audit row carry the same `prompt_name IS ?` the
|
|
543
|
+
* `UPDATE` carries. Without it a refused rename would still be booked as done, and the replay
|
|
544
|
+
* path would hand the caller back a node it never wrote.
|
|
545
|
+
*
|
|
546
|
+
* ⚠️ `other.id <> ?` is what lets a node keep its OWN name. Writing the name it already holds
|
|
547
|
+
* is the everyday case — every `node_update` that changes a title sends the current prompt
|
|
548
|
+
* name along — and without the exclusion the node would be refused for colliding with itself.
|
|
549
|
+
*/
|
|
532
550
|
async updateNode(input) {
|
|
533
551
|
const node = input.node;
|
|
534
552
|
try {
|
|
@@ -542,10 +560,19 @@ export function createNodeRepository(deps) {
|
|
|
542
560
|
JOIN descendants parent ON child.parent_id = parent.id
|
|
543
561
|
)
|
|
544
562
|
UPDATE nodes
|
|
545
|
-
SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
563
|
+
SET parent_id = ?, title = ?, description = ?, prompt_name = ?, updated_at = ?
|
|
546
564
|
WHERE id = ? AND updated_at = ?
|
|
547
|
-
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
|
|
548
|
-
|
|
565
|
+
AND (? IS NULL OR ? NOT IN (SELECT id FROM descendants))
|
|
566
|
+
AND (
|
|
567
|
+
? IS NULL
|
|
568
|
+
OR (
|
|
569
|
+
NOT EXISTS (
|
|
570
|
+
SELECT 1 FROM nodes other WHERE other.prompt_name = ? AND other.id <> ?
|
|
571
|
+
)
|
|
572
|
+
AND NOT EXISTS (SELECT 1 FROM flows holder WHERE holder.prompt_name = ?)
|
|
573
|
+
)
|
|
574
|
+
)`)
|
|
575
|
+
.bind(node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt, node.id, input.baseUpdatedAt, node.parentId, node.parentId, node.promptName, node.promptName, node.id, node.promptName),
|
|
549
576
|
deps.db
|
|
550
577
|
.prepare(`INSERT INTO idempotency_keys (
|
|
551
578
|
actor_id, operation, idempotency_key, resource_id, created_at
|
|
@@ -553,9 +580,9 @@ export function createNodeRepository(deps) {
|
|
|
553
580
|
WHERE EXISTS (
|
|
554
581
|
SELECT 1 FROM nodes
|
|
555
582
|
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ?
|
|
556
|
-
AND updated_at = ?
|
|
583
|
+
AND prompt_name IS ? AND updated_at = ?
|
|
557
584
|
)`)
|
|
558
|
-
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.updatedAt),
|
|
585
|
+
.bind(input.actorId, input.idempotencyKey, node.id, node.updatedAt, node.id, node.parentId, node.title, node.description, node.promptName, node.updatedAt),
|
|
559
586
|
deps.db
|
|
560
587
|
.prepare(`INSERT INTO audit_events (
|
|
561
588
|
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
@@ -608,8 +635,33 @@ export function createNodeRepository(deps) {
|
|
|
608
635
|
if (cycle?.found === 1)
|
|
609
636
|
return "cycle";
|
|
610
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* ⚠️ **Which of the two refusals it was, asked only once the write has already failed.** The
|
|
640
|
+
* condition on the statement is what makes the guard hold; this read is what makes the answer
|
|
641
|
+
* sayable. Doing it the other way round — deciding here and writing unguarded — is the shape
|
|
642
|
+
* that loses the race it was written for.
|
|
643
|
+
*
|
|
644
|
+
* ⚠️ It is asked of BOTH tables, because the name is one catalogue across the two (#775). A
|
|
645
|
+
* check against `nodes` alone answers "free" for a name a published flow holds, and the write
|
|
646
|
+
* would then keep failing with a message that says the node was changed by another editor.
|
|
647
|
+
*/
|
|
648
|
+
if (node.promptName !== null) {
|
|
649
|
+
const taken = await deps.db
|
|
650
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ? AND id <> ?
|
|
651
|
+
UNION ALL
|
|
652
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ?
|
|
653
|
+
LIMIT 1`)
|
|
654
|
+
.bind(node.promptName, node.id, node.promptName)
|
|
655
|
+
.first();
|
|
656
|
+
if (taken?.found === 1)
|
|
657
|
+
return "prompt_name_taken";
|
|
658
|
+
}
|
|
611
659
|
return "conflict";
|
|
612
660
|
},
|
|
661
|
+
async promptNameHolder(actor, name) {
|
|
662
|
+
// One question, one implementation, shared with the flow repository — see `db-prompts.ts`.
|
|
663
|
+
return await promptNameHolder(deps, actor, name);
|
|
664
|
+
},
|
|
613
665
|
async archiveNode(input) {
|
|
614
666
|
const archiving = input.archivedAt !== null;
|
|
615
667
|
/**
|
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/bundle/bundle.js
CHANGED
|
@@ -992,6 +992,15 @@ export function createBundle(deps) {
|
|
|
992
992
|
});
|
|
993
993
|
}
|
|
994
994
|
flows.push({
|
|
995
|
+
/**
|
|
996
|
+
* ⚠️ **An import never carries a slash command over, and that is not a gap** (#775). A
|
|
997
|
+
* prompt name is unique across the whole installation the bundle lands IN, and the
|
|
998
|
+
* bundle knows nothing about it: importing a flow offered as `/billing` into a tree
|
|
999
|
+
* that already has one would fail the whole import over a name nobody chose here. The
|
|
1000
|
+
* import brings the content; offering it is a decision somebody makes afterwards, in
|
|
1001
|
+
* the installation that has to live with the name.
|
|
1002
|
+
*/
|
|
1003
|
+
promptName: null,
|
|
995
1004
|
id: entry.newId,
|
|
996
1005
|
parentId,
|
|
997
1006
|
title: plainTitle(entry.title),
|
|
@@ -1075,6 +1084,9 @@ export function createBundle(deps) {
|
|
|
1075
1084
|
if (version !== null)
|
|
1076
1085
|
versions.push(version);
|
|
1077
1086
|
nodes.push({
|
|
1087
|
+
// Not carried over, for the reason the flow half above spells out: the catalogue belongs
|
|
1088
|
+
// to the installation, not to the bundle (#775).
|
|
1089
|
+
promptName: null,
|
|
1078
1090
|
id: entry.newId,
|
|
1079
1091
|
parentId,
|
|
1080
1092
|
// Flows took the `continue` above; what reaches here is one of the six node kinds.
|
package/dist/flows/flows.js
CHANGED
|
@@ -1321,6 +1321,9 @@ export function createFlows(deps) {
|
|
|
1321
1321
|
const occurredAt = deps.now().toISOString();
|
|
1322
1322
|
return await deps.repository.insertFlow({
|
|
1323
1323
|
flow: {
|
|
1324
|
+
// A flow is offered at `flow_publish` and never at creation: publishing is the decision
|
|
1325
|
+
// that it is for other people, and an unpublished flow is in nobody's catalogue (#775).
|
|
1326
|
+
promptName: null,
|
|
1324
1327
|
id: deps.id(),
|
|
1325
1328
|
parentId: input.parentId,
|
|
1326
1329
|
title: plainTitle(input.title),
|
|
@@ -1641,7 +1644,26 @@ export function createFlows(deps) {
|
|
|
1641
1644
|
idempotencyKey: input.idempotencyKey,
|
|
1642
1645
|
auditId: deps.id(),
|
|
1643
1646
|
occurredAt: deps.now().toISOString(),
|
|
1647
|
+
// ⚠️ Spread rather than assigned, and `exactOptionalPropertyTypes` is only half the reason.
|
|
1648
|
+
// The repository distinguishes three states, and "the field is present carrying
|
|
1649
|
+
// `undefined`" is not one of them: it has to be ABSENT for "leave the name alone".
|
|
1650
|
+
...(input.promptName === undefined ? {} : { promptName: input.promptName }),
|
|
1644
1651
|
});
|
|
1652
|
+
if (published === "prompt_name_taken") {
|
|
1653
|
+
/**
|
|
1654
|
+
* ⚠️ **The holder is named only to somebody who may see it** — the same sentence
|
|
1655
|
+
* `node_update` gives, out of the same query (`db-prompts.ts`). The catalogue is one across
|
|
1656
|
+
* documents and flows, so the name a flow is refused for may well be held by a document,
|
|
1657
|
+
* and the refusal has to be able to say so.
|
|
1658
|
+
*/
|
|
1659
|
+
const holder = await deps.repository.promptNameHolder(actor, input.promptName);
|
|
1660
|
+
const held = holder === null
|
|
1661
|
+
? "It is already taken."
|
|
1662
|
+
: holder.title === null
|
|
1663
|
+
? `A ${holder.kind} you cannot see already uses it.`
|
|
1664
|
+
: `The ${holder.kind} "${holder.title}" already uses it.`;
|
|
1665
|
+
throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
|
|
1666
|
+
}
|
|
1645
1667
|
if (!published)
|
|
1646
1668
|
throw new IntelError(409, "flow_publish_conflict", "Flow could not be published");
|
|
1647
1669
|
return await requireFlow(actor, flow.id);
|
|
@@ -1776,11 +1798,16 @@ export function createFlows(deps) {
|
|
|
1776
1798
|
async listRuns(actor, input) {
|
|
1777
1799
|
// The history of a flow that has since been archived stays readable — the runs happened, and
|
|
1778
1800
|
// archiving is about what may start next (#112).
|
|
1779
|
-
|
|
1801
|
+
//
|
|
1802
|
+
// ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
|
|
1803
|
+
// answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
|
|
1804
|
+
// while which runs appear is decided per row in the repository — their own, plus every run of
|
|
1805
|
+
// a flow they may read. Asking it for a flow nobody named would mean inventing one.
|
|
1806
|
+
const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
|
|
1780
1807
|
// One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
|
|
1781
1808
|
// count over the whole table is never needed to draw a "next" affordance.
|
|
1782
1809
|
const rows = await deps.repository.listRunsVisible(actor, {
|
|
1783
|
-
flowId
|
|
1810
|
+
flowId,
|
|
1784
1811
|
failedOnly: input.failedOnly,
|
|
1785
1812
|
limit: input.limit + 1,
|
|
1786
1813
|
cursor: decodeCursor(input.cursor),
|
|
@@ -2,6 +2,7 @@ import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph,
|
|
|
2
2
|
import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
|
|
3
3
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
4
4
|
import type { ResourceAccessList, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeFlowGrantInput, RevokeGrantResult, ShareFlowInput, ShareResult } from "@anchrd/intel-contract/share";
|
|
5
|
+
import type { PromptNameHolder } from "../prompts/prompts.types.js";
|
|
5
6
|
export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
|
|
6
7
|
export type FlowCallReach = "subtree" | "library" | "out-of-reach";
|
|
7
8
|
export interface FlowRunChainEntry {
|
|
@@ -13,7 +14,7 @@ export interface FlowRunChainEntry {
|
|
|
13
14
|
currentNodeId: string | null;
|
|
14
15
|
}
|
|
15
16
|
export interface ListRunsQuery {
|
|
16
|
-
flowId: string;
|
|
17
|
+
flowId: string | null;
|
|
17
18
|
failedOnly: boolean;
|
|
18
19
|
limit: number;
|
|
19
20
|
cursor: {
|
|
@@ -159,7 +160,20 @@ export interface FlowRepository {
|
|
|
159
160
|
idempotencyKey: string;
|
|
160
161
|
auditId: string;
|
|
161
162
|
occurredAt: string;
|
|
162
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The slash command this flow is offered under (#775). ⚠️ Three states, and `undefined` is the
|
|
165
|
+
* one that does nothing: publishing a second version of an already-offered flow must not
|
|
166
|
+
* withdraw its command, so a caller who says nothing about prompts changes nothing about them.
|
|
167
|
+
* `null` gives the name up, a string takes one.
|
|
168
|
+
*/
|
|
169
|
+
promptName?: string | null;
|
|
170
|
+
}): Promise<boolean | "prompt_name_taken">;
|
|
171
|
+
/**
|
|
172
|
+
* Who holds a slash command name, for the sentence a refused publish gets. `title` is `null` when
|
|
173
|
+
* the holder exists but this actor may not see it — the same one implementation the node
|
|
174
|
+
* repository answers with (`db-prompts.ts`).
|
|
175
|
+
*/
|
|
176
|
+
promptNameHolder(actor: FlowActor, name: string): Promise<PromptNameHolder | null>;
|
|
163
177
|
unpublish(input: {
|
|
164
178
|
flowId: string;
|
|
165
179
|
versionId: string;
|
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") });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { documentText } from "../shared/document-text/document-text.js";
|
|
2
2
|
export class PermanentIndexingError extends Error {
|
|
3
3
|
}
|
|
4
4
|
// The key under which a node with exactly one vector is filed — since #390 that is every kind. It
|
|
@@ -9,16 +9,15 @@ const wholeNodeChunkKey = "";
|
|
|
9
9
|
// was archived while it worked and the record of them was refused (anchrd/intel#348).
|
|
10
10
|
const writtenKeys = (chunks) => chunks.map((chunk) => chunk.key);
|
|
11
11
|
function indexText(mediaType, content) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// requeue the version until the queue gives up and would bury the real cause.
|
|
20
|
-
throw new PermanentIndexingError(`Node version content is not a valid BlockNote document: ${error instanceof Error ? error.message : "unknown parse failure"}`);
|
|
12
|
+
const text = documentText(mediaType, content);
|
|
13
|
+
// Immutable content never becomes parsable on a retry, so treating this as transient would
|
|
14
|
+
// requeue the version until the queue gives up and would bury the real cause. `documentText`
|
|
15
|
+
// answers `null` rather than throwing, because the other caller — `prompts/get` — owes its reader
|
|
16
|
+
// a different sentence about the same broken payload (#775).
|
|
17
|
+
if (text === null) {
|
|
18
|
+
throw new PermanentIndexingError("Node version content is not a valid BlockNote document");
|
|
21
19
|
}
|
|
20
|
+
return text;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* What one chunk is embedded as (anchrd/intel#301).
|
package/dist/intel/intel.js
CHANGED
|
@@ -6,6 +6,7 @@ import type { BundleService } from "../bundle/bundle.types.js";
|
|
|
6
6
|
import type { FeedService } from "../feed/feed.types.js";
|
|
7
7
|
import type { FlowService } from "../flows/flows.types.js";
|
|
8
8
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
9
|
+
import type { Prompts } from "../prompts/prompts.types.js";
|
|
9
10
|
import type { ToolService } from "../tools/tools.types.js";
|
|
10
11
|
export interface IntelDeps {
|
|
11
12
|
baseUrl: string;
|
|
@@ -18,5 +19,6 @@ export interface IntelDeps {
|
|
|
18
19
|
audit: AuditService;
|
|
19
20
|
feed: FeedService;
|
|
20
21
|
boards: BoardService;
|
|
22
|
+
prompts: Prompts;
|
|
21
23
|
auth?: BrowserAuth;
|
|
22
24
|
}
|