@anchrd/intel-api 0.6.7 → 0.9.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/README.md +63 -3
- package/dist/adapters/cloudflare/cloudflare.js +102 -37
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +20 -0
- package/dist/adapters/content/content.d.ts +1 -1
- package/dist/adapters/db/db-flows.js +148 -20
- package/dist/adapters/db/db-grants.d.ts +13 -2
- package/dist/adapters/db/db-grants.js +25 -8
- package/dist/adapters/db/db-indexing.d.ts +2 -2
- package/dist/adapters/db/db-indexing.js +26 -19
- package/dist/adapters/db/db.d.ts +3 -3
- package/dist/adapters/db/db.js +442 -118
- package/dist/adapters/gate-applications/gate-applications.d.ts +23 -0
- package/dist/adapters/gate-applications/gate-applications.js +88 -0
- package/dist/adapters/index-queue/index-queue.d.ts +1 -1
- package/dist/adapters/index-queue/index-queue.js +2 -2
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +2 -2
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +22 -0
- package/dist/adapters/tool-delegation/tool-delegation.js +90 -0
- package/dist/agent-runtime/agent-runtime.d.ts +16 -0
- package/dist/agent-runtime/agent-runtime.js +150 -0
- package/dist/agent-runtime/agent-runtime.types.d.ts +122 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1048 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.js +10 -1
- package/dist/flows/flows.d.ts +8 -8
- package/dist/flows/flows.js +158 -42
- package/dist/flows/flows.types.d.ts +40 -7
- package/dist/http/http.d.ts +1 -0
- package/dist/http/http.js +348 -61
- package/dist/http/http.types.d.ts +6 -2
- package/dist/indexing/indexing.js +14 -2
- package/dist/indexing/indexing.types.d.ts +2 -2
- package/dist/intel/intel.js +12 -3
- package/dist/intel/intel.types.d.ts +6 -2
- package/dist/mcp/mcp.js +519 -124
- package/dist/mcp/mcp.types.d.ts +11 -2
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1466 -0
- package/dist/nodes/nodes.types.d.ts +402 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +114 -0
- package/dist/tools/tools.js +190 -31
- package/dist/tools/tools.types.d.ts +23 -1
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0013_agents_in_the_tree.sql +76 -0
- package/migrations/0014_agent_applications.sql +25 -0
- package/migrations/0015_tools_delegated_from_a_connection.sql +15 -0
- package/package.json +3 -2
- package/dist/knowledge/knowledge.d.ts +0 -2
- package/dist/knowledge/knowledge.js +0 -761
- package/dist/knowledge/knowledge.types.d.ts +0 -198
- /package/dist/{knowledge/knowledge.types.js → agent-runtime/agent-runtime.types.js} +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.d.ts +0 -0
- /package/dist/{knowledge → nodes}/document-links/document-links.js +0 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import { AgentDefinition, AgentMediaType, TableMediaType, } from "@anchrd/intel-contract";
|
|
2
|
+
import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
|
|
3
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
4
|
+
import { documentLinkTargets } from "./document-links/document-links.js";
|
|
5
|
+
// ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
|
|
6
|
+
// is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
|
|
7
|
+
// and nothing lists the bucket by prefix. So the two prefixes cost nothing, while rewriting the old
|
|
8
|
+
// ones would mean copying every object in the bucket to change a string nobody reads.
|
|
9
|
+
function contentKeyFor(nodeId, versionId) {
|
|
10
|
+
return `nodes/${nodeId}/versions/${versionId}`;
|
|
11
|
+
}
|
|
12
|
+
// A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
|
|
13
|
+
// The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
|
|
14
|
+
//
|
|
15
|
+
// `execute` is meaningful where something can be run: a folder, because only a folder can hold a
|
|
16
|
+
// flow, and an agent, where it means being allowed to USE it (#139). Reading an agent's definition
|
|
17
|
+
// and being permitted to put it to work are separate questions — the whole reason the verbs are
|
|
18
|
+
// granted independently rather than as a ladder — and an agent is the case that makes the
|
|
19
|
+
// difference obvious: its definition is inspectable by design (ADR-0005 §2), so `read` on it must
|
|
20
|
+
// not imply the right to set it going.
|
|
21
|
+
function applicableVerbs(kind) {
|
|
22
|
+
return kind === "folder" || kind === "agent"
|
|
23
|
+
? ["read", "write", "execute", "share"]
|
|
24
|
+
: ["read", "write", "share"];
|
|
25
|
+
}
|
|
26
|
+
// ⚠️ The refusal has to be actionable without becoming a directory of the tree. Whoever holds
|
|
27
|
+
// `share` on one folder must not learn the titles of flows they may not see, so the ones they may
|
|
28
|
+
// see are named and the rest are only counted (ADR-0004 §3, and #17's review).
|
|
29
|
+
function callersDetail(callers) {
|
|
30
|
+
const named = callers.visible.map((title) => `“${title}”`).join(", ");
|
|
31
|
+
const rest = callers.hidden === 0
|
|
32
|
+
? ""
|
|
33
|
+
: `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
|
|
34
|
+
return `Flows outside this folder call into it: ${named}${rest}. Change or unpublish them before narrowing the folder.`;
|
|
35
|
+
}
|
|
36
|
+
// The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own,
|
|
37
|
+
// never `isAdmin`. It answers for Intel's resource ACLs only — whether Gate hands this person
|
|
38
|
+
// `intel/admin` is Gate's to know, so the warning below can be pessimistic and never permissive.
|
|
39
|
+
// A grant to an email address is judged as that address, which is what the grant will be attached
|
|
40
|
+
// to; a second grant the same person holds under their user ID is not folded in.
|
|
41
|
+
function asPrincipalActor(principal) {
|
|
42
|
+
if (principal.type === "user")
|
|
43
|
+
return { id: principal.id, email: "" };
|
|
44
|
+
if (principal.type === "email")
|
|
45
|
+
return { id: "", email: principal.email };
|
|
46
|
+
return { id: "", email: "" };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A stored definition, parsed rather than trusted — the version boundary the repository rule names
|
|
50
|
+
* (`CLAUDE.md`, "stored Flow graphs read across a version boundary").
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ It fails loudly on purpose. Intel is the only writer of this body, so a definition that does
|
|
53
|
+
* not parse is corruption, not an older shape to be coerced into the current one. Reading it
|
|
54
|
+
* leniently would hand the runtime an agent that has quietly forgotten its references, and the
|
|
55
|
+
* first sign of it would be an agent answering without the knowledge it was given.
|
|
56
|
+
*/
|
|
57
|
+
function parseStoredDefinition(body) {
|
|
58
|
+
let json;
|
|
59
|
+
try {
|
|
60
|
+
json = JSON.parse(body);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new IntelError(500, "agent_definition_invalid", "Agent definition is not valid JSON");
|
|
64
|
+
}
|
|
65
|
+
const parsed = AgentDefinition.safeParse(json);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
throw new IntelError(500, "agent_definition_invalid", "Agent definition does not match the contract");
|
|
68
|
+
}
|
|
69
|
+
return parsed.data;
|
|
70
|
+
}
|
|
71
|
+
function decodeBase64(value) {
|
|
72
|
+
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
73
|
+
throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new IntelError(400, "attachment_invalid", "Attachment content is not valid base64");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function createNodes(deps) {
|
|
83
|
+
function mergeSearchResults(lexical, semantic, semanticScores, limit) {
|
|
84
|
+
const merged = new Map();
|
|
85
|
+
for (const citation of lexical) {
|
|
86
|
+
merged.set(citation.nodeId, {
|
|
87
|
+
citation,
|
|
88
|
+
lexicalScore: citation.score,
|
|
89
|
+
semanticScore: undefined,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const citation of semantic) {
|
|
93
|
+
const current = merged.get(citation.nodeId);
|
|
94
|
+
merged.set(citation.nodeId, {
|
|
95
|
+
citation: current?.citation ?? citation,
|
|
96
|
+
lexicalScore: current?.lexicalScore,
|
|
97
|
+
semanticScore: semanticScores.get(citation.nodeId) ?? citation.score,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return [...merged.values()]
|
|
101
|
+
.map(({ citation, lexicalScore, semanticScore }) => {
|
|
102
|
+
const score = lexicalScore !== undefined && semanticScore !== undefined
|
|
103
|
+
? lexicalScore * 0.45 + semanticScore * 0.55
|
|
104
|
+
: lexicalScore !== undefined
|
|
105
|
+
? lexicalScore * 0.9
|
|
106
|
+
: (semanticScore ?? 0) * 0.85;
|
|
107
|
+
return {
|
|
108
|
+
...citation,
|
|
109
|
+
score: Math.max(0, Math.min(1, score)),
|
|
110
|
+
match: lexicalScore !== undefined && semanticScore !== undefined
|
|
111
|
+
? "hybrid"
|
|
112
|
+
: lexicalScore !== undefined
|
|
113
|
+
? "lexical"
|
|
114
|
+
: "semantic",
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.sort((left, right) => right.score - left.score || right.freshness.localeCompare(left.freshness))
|
|
118
|
+
.slice(0, limit);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A table's body: every version's R2 object joined in sequence order (#40).
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ This is the price of appending without reading. Each `append` writes one immutable object
|
|
124
|
+
* holding only the rows it added, so the write is O(new rows) and two concurrent appends cannot
|
|
125
|
+
* overwrite one another — but a read of a table with k appends costs k R2 gets. That is the
|
|
126
|
+
* trade the ticket asks for by name: the write side is the hot path an agent uses on a schedule,
|
|
127
|
+
* the read side is a person opening a grid or one indexing pass. The gets are issued together
|
|
128
|
+
* rather than in a chain so the cost is k requests, not k round trips; compacting old segments
|
|
129
|
+
* into one object is a later ticket, and it can happen without changing anything a caller sees
|
|
130
|
+
* because the version rows stay the history either way.
|
|
131
|
+
*/
|
|
132
|
+
async function tableContent(node) {
|
|
133
|
+
if (node.currentVersionId === null)
|
|
134
|
+
return "";
|
|
135
|
+
// ⚠️ One statement for every segment key, never one per segment, and only the keys rather than
|
|
136
|
+
// the whole version rows. The number of D1 round trips a table costs must not grow with the
|
|
137
|
+
// number of times it has been appended to (#30).
|
|
138
|
+
const keys = await deps.repository.listVersionContentKeys(node.id);
|
|
139
|
+
const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
|
|
140
|
+
if (segments.some((segment) => segment === null)) {
|
|
141
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
142
|
+
}
|
|
143
|
+
return segments.join("");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The column names, read from the first version alone.
|
|
147
|
+
*
|
|
148
|
+
* ⚠️ Deliberately not `tableContent`, and deliberately not the version list either. Checking an
|
|
149
|
+
* append against the header runs on every append, so it must cost the same on a table of ten rows
|
|
150
|
+
* and on one of ten thousand: one statement that returns one key, and one small R2 read. Reading
|
|
151
|
+
* every version row to look at the first would be a cost that grows with the history — one query,
|
|
152
|
+
* but more of it every time (#30, #40).
|
|
153
|
+
*/
|
|
154
|
+
async function tableHeader(node) {
|
|
155
|
+
if (node.currentVersionId === null)
|
|
156
|
+
return null;
|
|
157
|
+
const key = await deps.repository.tableHeaderContentKey(node.id);
|
|
158
|
+
if (key === null)
|
|
159
|
+
return null;
|
|
160
|
+
const body = await deps.content.get(key);
|
|
161
|
+
if (body === null)
|
|
162
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
163
|
+
return parseCsv(body)[0] ?? null;
|
|
164
|
+
}
|
|
165
|
+
async function getDocument(node) {
|
|
166
|
+
if (node.currentVersionId === null)
|
|
167
|
+
return { node, version: null, content: null };
|
|
168
|
+
const version = await deps.repository.getVersion(node.currentVersionId);
|
|
169
|
+
if (!version)
|
|
170
|
+
throw new IntelError(500, "version_missing", "Current version is missing");
|
|
171
|
+
if (node.kind === "attachment")
|
|
172
|
+
return { node, version, content: null };
|
|
173
|
+
// A table answers with the whole CSV, so downloading, citing and reading it over MCP all get
|
|
174
|
+
// the same bytes a person sees in the grid — the format is the export (#40).
|
|
175
|
+
if (node.kind === "table")
|
|
176
|
+
return { node, version, content: await tableContent(node) };
|
|
177
|
+
const content = await deps.content.get(version.contentKey);
|
|
178
|
+
if (content === null)
|
|
179
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
180
|
+
return { node, version, content };
|
|
181
|
+
}
|
|
182
|
+
async function tableOf(node) {
|
|
183
|
+
const [header = [], ...rows] = parseCsv(await tableContent(node));
|
|
184
|
+
return { node, columns: header, rows, versionId: node.currentVersionId };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One immutable object holding only what this write adds, plus the version row that orders it.
|
|
188
|
+
*
|
|
189
|
+
* ⚠️ The R2 object is written before the version row and is deleted again if the row does not
|
|
190
|
+
* land, exactly as `save` does it. An orphaned object is invisible; a version row pointing at
|
|
191
|
+
* nothing is a table that cannot be read at all.
|
|
192
|
+
*/
|
|
193
|
+
async function writeTableSegment(actor, node, body, segment, idempotencyKey) {
|
|
194
|
+
const versionId = deps.id();
|
|
195
|
+
const contentKey = contentKeyFor(node.id, versionId);
|
|
196
|
+
await deps.content.put(contentKey, body, TableMediaType);
|
|
197
|
+
try {
|
|
198
|
+
return await deps.repository.appendTableVersion({
|
|
199
|
+
version: {
|
|
200
|
+
id: versionId,
|
|
201
|
+
nodeId: node.id,
|
|
202
|
+
contentKey,
|
|
203
|
+
mediaType: TableMediaType,
|
|
204
|
+
contentHash: await deps.hash(body),
|
|
205
|
+
size: new TextEncoder().encode(body).byteLength,
|
|
206
|
+
segment,
|
|
207
|
+
createdBy: actor.id,
|
|
208
|
+
createdAt: deps.now().toISOString(),
|
|
209
|
+
},
|
|
210
|
+
actorId: actor.id,
|
|
211
|
+
idempotencyKey,
|
|
212
|
+
auditId: deps.id(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
await deps.content.delete(contentKey).catch(() => undefined);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* One snapshot — the complete table after a mutation — written only against the state the caller
|
|
222
|
+
* read (#135).
|
|
223
|
+
*
|
|
224
|
+
* ⚠️ The base check lives in the repository's INSERT, not in a read here: between reading the
|
|
225
|
+
* table and writing its replacement another segment can always land, and only the statement that
|
|
226
|
+
* inserts the row can refuse at the moment it matters. On "conflict" the R2 object is removed
|
|
227
|
+
* again and the idempotency table is asked once more, exactly as `save` does it — the conflict
|
|
228
|
+
* may be this very mutation, already written by a racing retry with the same key.
|
|
229
|
+
*/
|
|
230
|
+
async function writeTableSnapshot(input) {
|
|
231
|
+
const versionId = deps.id();
|
|
232
|
+
const contentKey = contentKeyFor(input.node.id, versionId);
|
|
233
|
+
const version = {
|
|
234
|
+
id: versionId,
|
|
235
|
+
nodeId: input.node.id,
|
|
236
|
+
sequence: await nextSequence(input.node),
|
|
237
|
+
contentKey,
|
|
238
|
+
mediaType: TableMediaType,
|
|
239
|
+
contentHash: await deps.hash(input.body),
|
|
240
|
+
size: new TextEncoder().encode(input.body).byteLength,
|
|
241
|
+
segment: "snapshot",
|
|
242
|
+
createdBy: input.actor.id,
|
|
243
|
+
createdAt: deps.now().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
await deps.content.put(contentKey, input.body, TableMediaType);
|
|
246
|
+
let saved;
|
|
247
|
+
try {
|
|
248
|
+
saved = await deps.repository.appendTableSnapshot({
|
|
249
|
+
version,
|
|
250
|
+
actorId: input.actor.id,
|
|
251
|
+
baseVersionId: input.baseVersionId,
|
|
252
|
+
operation: input.operation,
|
|
253
|
+
metadata: input.metadata,
|
|
254
|
+
idempotencyKey: input.idempotencyKey,
|
|
255
|
+
auditId: deps.id(),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
await deps.content.delete(contentKey).catch(() => undefined);
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
if (saved === "conflict") {
|
|
263
|
+
await deps.content.delete(contentKey);
|
|
264
|
+
const replayedId = await deps.repository.findIdempotentNode(input.actor.id, input.operation, input.idempotencyKey);
|
|
265
|
+
if (replayedId) {
|
|
266
|
+
const replayed = await deps.repository.getVersion(replayedId);
|
|
267
|
+
if (replayed)
|
|
268
|
+
return replayed;
|
|
269
|
+
}
|
|
270
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
271
|
+
}
|
|
272
|
+
await deps.indexing.enqueue(version.id);
|
|
273
|
+
return version;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The current state a mutation addresses: header and rows, read only after the base was checked.
|
|
277
|
+
*
|
|
278
|
+
* ⚠️ The refusal for a stale base comes before the content is read, so a caller with an outdated
|
|
279
|
+
* `baseVersionId` costs one D1 row and no R2 traffic — and the position validation that follows
|
|
280
|
+
* never runs against rows the caller was not looking at.
|
|
281
|
+
*/
|
|
282
|
+
async function tableStateFor(node, baseVersionId) {
|
|
283
|
+
if (node.currentVersionId === null) {
|
|
284
|
+
throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before changing rows");
|
|
285
|
+
}
|
|
286
|
+
if (node.currentVersionId !== baseVersionId) {
|
|
287
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
288
|
+
}
|
|
289
|
+
const [header, ...rows] = parseCsv(await tableContent(node));
|
|
290
|
+
if (!header)
|
|
291
|
+
throw new IntelError(500, "content_missing", "Table header is missing");
|
|
292
|
+
return { header, rows };
|
|
293
|
+
}
|
|
294
|
+
// The same refusal an append gives, for the same reason: a row that does not fit the header is a
|
|
295
|
+
// caller that believes the table has a different shape, and quietly filling or cutting the gap
|
|
296
|
+
// would store that misunderstanding as data (#40, unchanged by #135).
|
|
297
|
+
function requireRowShape(rows, header) {
|
|
298
|
+
const wrong = rows.findIndex((row) => row.length !== header.length);
|
|
299
|
+
if (wrong !== -1) {
|
|
300
|
+
throw new IntelError(400, "table_row_shape", `Row ${wrong + 1} has ${rows[wrong]?.length ?? 0} cells but the table has ${header.length} columns: ${header.join(", ")}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// A position that misses the table is the same kind of misunderstanding as a row that misses the
|
|
304
|
+
// header: refused whole, nothing written, and the message says what the table actually holds.
|
|
305
|
+
function requirePositions(positions, rowCount) {
|
|
306
|
+
const outside = positions.find((position) => position >= rowCount);
|
|
307
|
+
if (outside !== undefined) {
|
|
308
|
+
throw new IntelError(400, "table_row_position", `Row position ${outside} is out of range: the table has ${rowCount} row${rowCount === 1 ? "" : "s"}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function agentOf(node) {
|
|
312
|
+
if (node.kind !== "agent") {
|
|
313
|
+
throw new IntelError(409, "not_an_agent", "This node is not an agent");
|
|
314
|
+
}
|
|
315
|
+
// The Application ID rides on every agent read (#182). It is a name and not a credential, so
|
|
316
|
+
// there is nothing to withhold — and the surface that draws an agent has to be able to say
|
|
317
|
+
// whether it has a principal at all, because an agent without one runs nothing.
|
|
318
|
+
const applicationId = await deps.repository.agentApplicationId(node.id);
|
|
319
|
+
if (node.currentVersionId === null) {
|
|
320
|
+
return { node, version: null, definition: null, applicationId };
|
|
321
|
+
}
|
|
322
|
+
const version = await deps.repository.getVersion(node.currentVersionId);
|
|
323
|
+
if (!version)
|
|
324
|
+
throw new IntelError(500, "version_missing", "Current version is missing");
|
|
325
|
+
const body = await deps.content.get(version.contentKey);
|
|
326
|
+
if (body === null)
|
|
327
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
328
|
+
return { node, version, definition: parseStoredDefinition(body), applicationId };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The one place a caller's asked-for definition becomes the definition Intel stores (D30).
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ `delegatedBy` is ADDED here, from the session, and could not have arrived any other way:
|
|
334
|
+
* `AgentDefinitionInput` has no field for it, so a body naming somebody else is a parse error at
|
|
335
|
+
* the boundary rather than a value this function has to remember to ignore. Adding it here is
|
|
336
|
+
* safe in exactly one direction — the writer must prove below that they reach every named server
|
|
337
|
+
* themselves, so the delegation can only ever move to somebody who already had it.
|
|
338
|
+
*
|
|
339
|
+
* ⚠️ The check is a live `tools/list` through the portal, before anything is written. A server the
|
|
340
|
+
* saver cannot reach is refused BY NAME — a silent drop would leave a person looking at a saved
|
|
341
|
+
* agent that quietly has one tool fewer than they picked.
|
|
342
|
+
*/
|
|
343
|
+
async function delegationOf(actor, definition) {
|
|
344
|
+
const tools = definition.tools;
|
|
345
|
+
if (!tools)
|
|
346
|
+
return { ...definition, tools: null };
|
|
347
|
+
const servers = [...new Set(tools.servers)];
|
|
348
|
+
if (servers.length === 0) {
|
|
349
|
+
return { ...definition, tools: { delegatedBy: actor.id, servers: [] } };
|
|
350
|
+
}
|
|
351
|
+
const reachable = new Set(await deps.toolServers(actor));
|
|
352
|
+
const missing = servers.filter((server) => !reachable.has(server));
|
|
353
|
+
if (missing.length > 0) {
|
|
354
|
+
throw new IntelError(403, "tool_server_not_delegatable", `You do not reach these MCP servers, so you cannot delegate them: ${missing.join(", ")}`);
|
|
355
|
+
}
|
|
356
|
+
return { ...definition, tools: { delegatedBy: actor.id, servers } };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* One immutable definition version, written exactly the way a document's content is (ADR-0005 §1).
|
|
360
|
+
*
|
|
361
|
+
* ⚠️ R2 object first, version row second, R2 object deleted again if the row does not land — the
|
|
362
|
+
* same order `save` keeps and for the same reason: an orphaned object is invisible, while a
|
|
363
|
+
* version row pointing at nothing is an agent that cannot be read at all.
|
|
364
|
+
*
|
|
365
|
+
* ⚠️ The body is serialized from the PARSED definition, never from the caller's JSON text. What
|
|
366
|
+
* is stored is therefore always what the contract accepted, and a field the strict schema refused
|
|
367
|
+
* cannot reach R2 by riding along in the original string.
|
|
368
|
+
*
|
|
369
|
+
* ⚠️ `segment` is `null`, like a document's and unlike a table's (#135): every version here holds
|
|
370
|
+
* the whole definition, so there is nothing for a snapshot to mark off from what came before it.
|
|
371
|
+
*/
|
|
372
|
+
async function writeAgentVersion(actor, node, definition, baseVersionId, idempotencyKey) {
|
|
373
|
+
const body = JSON.stringify(definition);
|
|
374
|
+
const versionId = deps.id();
|
|
375
|
+
const contentKey = contentKeyFor(node.id, versionId);
|
|
376
|
+
const version = {
|
|
377
|
+
id: versionId,
|
|
378
|
+
nodeId: node.id,
|
|
379
|
+
sequence: await nextSequence(node),
|
|
380
|
+
contentKey,
|
|
381
|
+
mediaType: AgentMediaType,
|
|
382
|
+
contentHash: await deps.hash(body),
|
|
383
|
+
size: new TextEncoder().encode(body).byteLength,
|
|
384
|
+
segment: null,
|
|
385
|
+
createdBy: actor.id,
|
|
386
|
+
createdAt: deps.now().toISOString(),
|
|
387
|
+
};
|
|
388
|
+
await deps.content.put(contentKey, body, AgentMediaType);
|
|
389
|
+
let saved;
|
|
390
|
+
try {
|
|
391
|
+
saved = await deps.repository.appendVersion({
|
|
392
|
+
version,
|
|
393
|
+
actorId: actor.id,
|
|
394
|
+
baseVersionId,
|
|
395
|
+
idempotencyKey,
|
|
396
|
+
auditId: deps.id(),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
await deps.content.delete(contentKey).catch(() => undefined);
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
if (saved === "conflict") {
|
|
404
|
+
await deps.content.delete(contentKey);
|
|
405
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
406
|
+
}
|
|
407
|
+
await deps.indexing.enqueue(version.id);
|
|
408
|
+
return {
|
|
409
|
+
node: await requireVisible(actor, node.id),
|
|
410
|
+
version,
|
|
411
|
+
definition,
|
|
412
|
+
applicationId: await deps.repository.agentApplicationId(node.id),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async function requireTable(actor, nodeId) {
|
|
416
|
+
const node = await requireVisible(actor, nodeId);
|
|
417
|
+
if (node.kind !== "table") {
|
|
418
|
+
throw new IntelError(409, "not_a_table", "Only tables accept rows");
|
|
419
|
+
}
|
|
420
|
+
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
421
|
+
throw new IntelError(403, "node_forbidden", "Table cannot be edited");
|
|
422
|
+
}
|
|
423
|
+
return node;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* What the grant just written does not cover: the documents the flows in this folder read that
|
|
427
|
+
* the new principal still cannot.
|
|
428
|
+
*
|
|
429
|
+
* ⚠️ It warns and never blocks. A node reference across the folder edge is a possible
|
|
430
|
+
* failure, not a way around permissions — that distinction is the whole of ADR-0004 §4, and a
|
|
431
|
+
* block here would force everyone using one central policy document to duplicate it.
|
|
432
|
+
*
|
|
433
|
+
* ⚠️ Which documents may be named is `getVisible`, the lookup every other node read goes
|
|
434
|
+
* through, and never a second rule written for a message: the ones this actor may see are named,
|
|
435
|
+
* the rest are counted. That is exactly where #17 and #19 went wrong in review.
|
|
436
|
+
*
|
|
437
|
+
* The flow side is filtered by the flow list's own predicate as well. It changes nothing today —
|
|
438
|
+
* sharing needs `read` on the folder and `read` inherits over the whole subtree, so every flow in
|
|
439
|
+
* it is already visible — but the verbs are independent by decision (ADR-0004 §2), and the day
|
|
440
|
+
* `share` stops implying `read` this must not be the place that quietly starts leaking.
|
|
441
|
+
*
|
|
442
|
+
* It does say one thing about the grantee: that a named document is not readable for them. That
|
|
443
|
+
* is the consequence of this actor's own decision, on their own folder, at the moment they make
|
|
444
|
+
* it, and it is the smallest answer that lets them make it — the alternative is the flow failing
|
|
445
|
+
* for someone else next week with nobody able to say why. It never enumerates the principal's
|
|
446
|
+
* access in general and never leaves the documents these flows actually name.
|
|
447
|
+
*/
|
|
448
|
+
/**
|
|
449
|
+
* The nodes the agents in this folder's subtree name in their definitions (#139, ADR-0005 §2).
|
|
450
|
+
*
|
|
451
|
+
* ⚠️ Read from R2 and parsed, because an agent's references live in its definition body and not
|
|
452
|
+
* in D1 — unlike a flow's, which sit in `flow_versions.graph_json` and can be matched in SQL.
|
|
453
|
+
* There is no query that can answer this, and writing one against a guessed JSON shape is exactly
|
|
454
|
+
* how the flow side came to match a graph that migration 0008 had already abolished
|
|
455
|
+
* (anchrd/intel#153): the query kept returning nothing and the warning silently stopped arriving.
|
|
456
|
+
* Parsing the real contract schema is what makes this one fail loudly instead of quietly.
|
|
457
|
+
*
|
|
458
|
+
* ⚠️ A definition that will not parse is SKIPPED rather than thrown — the opposite of the read
|
|
459
|
+
* path above, deliberately. This runs while somebody is sharing a folder, and a warning that
|
|
460
|
+
* raises would turn one broken agent into a folder nobody can share. The read path is where a
|
|
461
|
+
* corrupt definition has to be loud; here the cost of loudness is paid by the wrong person.
|
|
462
|
+
*/
|
|
463
|
+
async function agentNodeReferences(actor, folderId) {
|
|
464
|
+
const keys = await deps.repository.listVisibleAgentDefinitionKeys(actor, folderId);
|
|
465
|
+
const referenced = [];
|
|
466
|
+
for (const key of keys) {
|
|
467
|
+
const body = await deps.content.get(key);
|
|
468
|
+
if (body === null)
|
|
469
|
+
continue;
|
|
470
|
+
try {
|
|
471
|
+
const parsed = AgentDefinition.safeParse(JSON.parse(body));
|
|
472
|
+
if (!parsed.success)
|
|
473
|
+
continue;
|
|
474
|
+
for (const reference of parsed.data.references)
|
|
475
|
+
referenced.push(reference.nodeId);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Unparsable JSON is skipped for the reason above: this runs inside a share, and one
|
|
479
|
+
// broken agent must not be able to make a folder unshareable.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return referenced;
|
|
483
|
+
}
|
|
484
|
+
async function unreadableForPrincipal(actor, folderId, principal) {
|
|
485
|
+
const grantee = asPrincipalActor(principal);
|
|
486
|
+
const titles = [];
|
|
487
|
+
let hidden = 0;
|
|
488
|
+
// Flows and agents reach for material the same way and the grant covers neither, so they are
|
|
489
|
+
// one list. Deduplicated, because a document that is both a flow's input and an agent's system
|
|
490
|
+
// message is one thing the new principal cannot read, not two.
|
|
491
|
+
const referenced = [
|
|
492
|
+
...new Set([
|
|
493
|
+
...(await deps.flowNodeReferences(actor, folderId)),
|
|
494
|
+
...(await agentNodeReferences(actor, folderId)),
|
|
495
|
+
]),
|
|
496
|
+
];
|
|
497
|
+
for (const resourceId of referenced) {
|
|
498
|
+
if (await deps.repository.can(grantee, resourceId, "read"))
|
|
499
|
+
continue;
|
|
500
|
+
const node = await deps.repository.getVisible(actor, resourceId);
|
|
501
|
+
if (node)
|
|
502
|
+
titles.push(node.title);
|
|
503
|
+
else
|
|
504
|
+
hidden += 1;
|
|
505
|
+
}
|
|
506
|
+
return { titles, hidden };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The graph, brought in line with what the saved document actually says (#41).
|
|
510
|
+
*
|
|
511
|
+
* A text link is the relationship — there is no second way to make one any more — so the links
|
|
512
|
+
* of a document are rewritten every time it is saved: what is no longer written is no longer
|
|
513
|
+
* there, and what was added is.
|
|
514
|
+
*
|
|
515
|
+
* ⚠️ Only targets this actor may see become links. The author can only insert what the picker
|
|
516
|
+
* offers them, but `node_save` takes any content over MCP, and an unfiltered write would
|
|
517
|
+
* turn the graph into a place where the existence of an unreachable document can be confirmed by
|
|
518
|
+
* anyone who guesses its ID. The filter is `resolveVisibleTitles`, the same lookup the reader's
|
|
519
|
+
* side goes through — one rule, not two.
|
|
520
|
+
*
|
|
521
|
+
* ⚠️ Rows made in the removed dialog are `manual` and are left alone. Saving a document must not
|
|
522
|
+
* silently delete a relationship somebody entered before there was another way to enter one.
|
|
523
|
+
*/
|
|
524
|
+
async function reconcileTextLinks(actor, sourceNodeId, mediaType, content) {
|
|
525
|
+
const written = documentLinkTargets(mediaType, content).filter((id) => id !== sourceNodeId);
|
|
526
|
+
const visible = written.length === 0
|
|
527
|
+
? []
|
|
528
|
+
: (await deps.repository.resolveVisibleTitles(actor, written)).map((entry) => entry.nodeId);
|
|
529
|
+
await deps.repository.replaceTextLinks({
|
|
530
|
+
sourceNodeId,
|
|
531
|
+
links: visible.map((targetNodeId) => ({ id: deps.id(), targetNodeId })),
|
|
532
|
+
actorId: actor.id,
|
|
533
|
+
auditId: deps.id(),
|
|
534
|
+
occurredAt: deps.now().toISOString(),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function requireVisible(actor, nodeId) {
|
|
538
|
+
const node = await deps.repository.getVisible(actor, nodeId);
|
|
539
|
+
if (!node)
|
|
540
|
+
throw new IntelError(404, "node_not_found", "Node was not found");
|
|
541
|
+
return node;
|
|
542
|
+
}
|
|
543
|
+
async function nextSequence(node) {
|
|
544
|
+
if (!node.currentVersionId)
|
|
545
|
+
return 1;
|
|
546
|
+
const current = await deps.repository.getVersion(node.currentVersionId);
|
|
547
|
+
if (!current) {
|
|
548
|
+
throw new IntelError(500, "version_missing", "Current node version is missing");
|
|
549
|
+
}
|
|
550
|
+
return current.sequence + 1;
|
|
551
|
+
}
|
|
552
|
+
async function attachment(actor, nodeId) {
|
|
553
|
+
const node = await requireVisible(actor, nodeId);
|
|
554
|
+
if (node.kind !== "attachment" || !node.currentVersionId) {
|
|
555
|
+
throw new IntelError(404, "attachment_not_found", "Attachment was not found");
|
|
556
|
+
}
|
|
557
|
+
const version = await deps.repository.getVersion(node.currentVersionId);
|
|
558
|
+
if (!version)
|
|
559
|
+
throw new IntelError(500, "version_missing", "Current version is missing");
|
|
560
|
+
return {
|
|
561
|
+
node,
|
|
562
|
+
version,
|
|
563
|
+
resourceUri: `intel://nodes/${encodeURIComponent(node.id)}/attachment`,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
async list(actor, input) {
|
|
568
|
+
return await deps.repository.listVisible(actor, input);
|
|
569
|
+
},
|
|
570
|
+
// The same level under a bound, for the one caller that draws a bounded picture of it. It goes
|
|
571
|
+
// through the same predicate as `list`, so what is drawn is a prefix of what is listed and never
|
|
572
|
+
// a different selection (#30).
|
|
573
|
+
async childrenBounded(actor, input) {
|
|
574
|
+
return await deps.repository.listVisibleBounded(actor, input);
|
|
575
|
+
},
|
|
576
|
+
async get(actor, nodeId) {
|
|
577
|
+
return await getDocument(await requireVisible(actor, nodeId));
|
|
578
|
+
},
|
|
579
|
+
/**
|
|
580
|
+
* The content of one pinned version (#147). Search citations pin the version they quoted, and
|
|
581
|
+
* without this read a citation could name text no surface can show any more.
|
|
582
|
+
*
|
|
583
|
+
* ⚠️ Authorization first, existence second: `requireVisible` answers 404 for a node this actor
|
|
584
|
+
* may not reach before the version table is asked anything, so probing version IDs proves
|
|
585
|
+
* nothing about nodes one cannot see. A version of ANOTHER node is the same 404 — a version ID
|
|
586
|
+
* is not an address of its own, only a position in the history of a node one may read.
|
|
587
|
+
*
|
|
588
|
+
* A table version answers with that segment's own rows (an append) or its snapshot state —
|
|
589
|
+
* deliberately not the assembled table of that moment, which is what `getTable` answers for
|
|
590
|
+
* the present. An attachment version answers with its metadata and `content: null`, the same
|
|
591
|
+
* shape `get` gives an attachment: old attachment bytes stay out of scope here (#147 allows
|
|
592
|
+
* that explicitly), because inlining them is the attachment resource's decision, not this one's.
|
|
593
|
+
*/
|
|
594
|
+
async getVersion(actor, nodeId, versionId) {
|
|
595
|
+
const node = await requireVisible(actor, nodeId);
|
|
596
|
+
const version = await deps.repository.getVersion(versionId);
|
|
597
|
+
if (!version || version.nodeId !== node.id) {
|
|
598
|
+
throw new IntelError(404, "version_not_found", "Version was not found");
|
|
599
|
+
}
|
|
600
|
+
if (node.kind === "attachment")
|
|
601
|
+
return { node, version, content: null };
|
|
602
|
+
const content = await deps.content.get(version.contentKey);
|
|
603
|
+
if (content === null) {
|
|
604
|
+
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
605
|
+
}
|
|
606
|
+
return { node, version, content };
|
|
607
|
+
},
|
|
608
|
+
// Nodes and Flows share one folder tree (ADR-0004), so Flows has to ask one question about
|
|
609
|
+
// it: may this actor file something in that folder. The answer stays here, with the tree and
|
|
610
|
+
// its ACLs, rather than being reimplemented on the flow side.
|
|
611
|
+
async folderAccess(actor, folderId) {
|
|
612
|
+
const folder = await deps.repository.getVisible(actor, folderId);
|
|
613
|
+
if (!folder || folder.archivedAt)
|
|
614
|
+
return "missing";
|
|
615
|
+
if (folder.kind !== "folder")
|
|
616
|
+
return "not-a-folder";
|
|
617
|
+
return (await deps.repository.can(actor, folder.id, "write")) ? "ok" : "forbidden";
|
|
618
|
+
},
|
|
619
|
+
async create(actor, input) {
|
|
620
|
+
// The generic path can file an agent row too — one without a definition or a principal — so
|
|
621
|
+
// the #190 gate stands here as well. Only where no runtime exists: with one, this path stays
|
|
622
|
+
// exactly as it was, which is part of the same ticket.
|
|
623
|
+
if (input.kind === "agent" && !deps.agentRuntimeAvailable()) {
|
|
624
|
+
throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
|
|
625
|
+
}
|
|
626
|
+
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
627
|
+
if (existingId)
|
|
628
|
+
return await requireVisible(actor, existingId);
|
|
629
|
+
if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
|
|
630
|
+
throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
|
|
631
|
+
}
|
|
632
|
+
const timestamp = deps.now().toISOString();
|
|
633
|
+
return await deps.repository.insertNode({
|
|
634
|
+
node: {
|
|
635
|
+
id: deps.id(),
|
|
636
|
+
parentId: input.parentId,
|
|
637
|
+
kind: input.kind,
|
|
638
|
+
title: input.title,
|
|
639
|
+
description: input.description,
|
|
640
|
+
ownerId: actor.id,
|
|
641
|
+
currentVersionId: null,
|
|
642
|
+
createdAt: timestamp,
|
|
643
|
+
updatedAt: timestamp,
|
|
644
|
+
archivedAt: null,
|
|
645
|
+
},
|
|
646
|
+
actorId: actor.id,
|
|
647
|
+
idempotencyKey: input.idempotencyKey,
|
|
648
|
+
auditId: deps.id(),
|
|
649
|
+
});
|
|
650
|
+
},
|
|
651
|
+
async save(actor, input) {
|
|
652
|
+
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
653
|
+
if (existingId) {
|
|
654
|
+
const document = await getDocument(await requireVisible(actor, input.nodeId));
|
|
655
|
+
await deps.indexing.enqueue(existingId);
|
|
656
|
+
return document;
|
|
657
|
+
}
|
|
658
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
659
|
+
if (node.kind !== "document") {
|
|
660
|
+
throw new IntelError(409, "document_content_required", "Only documents accept editor content versions");
|
|
661
|
+
}
|
|
662
|
+
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
663
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
664
|
+
}
|
|
665
|
+
if (node.currentVersionId !== input.baseVersionId) {
|
|
666
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
667
|
+
}
|
|
668
|
+
const versionId = deps.id();
|
|
669
|
+
const contentKey = contentKeyFor(node.id, versionId);
|
|
670
|
+
const createdAt = deps.now().toISOString();
|
|
671
|
+
const version = {
|
|
672
|
+
id: versionId,
|
|
673
|
+
nodeId: node.id,
|
|
674
|
+
sequence: await nextSequence(node),
|
|
675
|
+
contentKey,
|
|
676
|
+
mediaType: input.mediaType,
|
|
677
|
+
contentHash: await deps.hash(input.content),
|
|
678
|
+
size: new TextEncoder().encode(input.content).byteLength,
|
|
679
|
+
segment: null,
|
|
680
|
+
createdBy: actor.id,
|
|
681
|
+
createdAt,
|
|
682
|
+
};
|
|
683
|
+
await deps.content.put(contentKey, input.content, input.mediaType);
|
|
684
|
+
let saved;
|
|
685
|
+
try {
|
|
686
|
+
saved = await deps.repository.appendVersion({
|
|
687
|
+
version,
|
|
688
|
+
actorId: actor.id,
|
|
689
|
+
baseVersionId: input.baseVersionId,
|
|
690
|
+
idempotencyKey: input.idempotencyKey,
|
|
691
|
+
auditId: deps.id(),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
await deps.content.delete(contentKey).catch(() => undefined);
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
if (saved === "conflict") {
|
|
699
|
+
await deps.content.delete(contentKey);
|
|
700
|
+
const replayed = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
701
|
+
if (replayed)
|
|
702
|
+
return await getDocument(await requireVisible(actor, node.id));
|
|
703
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
704
|
+
}
|
|
705
|
+
const updated = await requireVisible(actor, node.id);
|
|
706
|
+
await reconcileTextLinks(actor, node.id, input.mediaType, input.content);
|
|
707
|
+
await deps.indexing.enqueue(version.id);
|
|
708
|
+
return { node: updated, version, content: input.content };
|
|
709
|
+
},
|
|
710
|
+
async getAgent(actor, input) {
|
|
711
|
+
return await agentOf(await requireVisible(actor, input.nodeId));
|
|
712
|
+
},
|
|
713
|
+
async listAgents(actor, input) {
|
|
714
|
+
return { items: await deps.repository.listVisibleAgents(actor, input) };
|
|
715
|
+
},
|
|
716
|
+
/**
|
|
717
|
+
* The Gate Application, the node, and its first definition, in that order.
|
|
718
|
+
*
|
|
719
|
+
* ⚠️ Two writes behind one call, so the replay has to be answered for both. The node
|
|
720
|
+
* carries the caller's key under `node.create`; the definition carries a derived one under
|
|
721
|
+
* `node.save`, because the two operations share an idempotency table and the same key twice
|
|
722
|
+
* would make the second write look like a replay of the first. A caller who repeats the
|
|
723
|
+
* request gets the agent that already exists rather than a second one beside it.
|
|
724
|
+
*
|
|
725
|
+
* ⚠️ A REPLAY MAKES NO SECOND APPLICATION. It still returns the agent that exists — that promise
|
|
726
|
+
* is not weakened — but a repeat that minted a fresh principal would leave the installation with
|
|
727
|
+
* two machine accounts for one agent, one of which nobody would ever switch off, and would
|
|
728
|
+
* replace a working agent's key with one it never asked for. Gate is not asked at all on that
|
|
729
|
+
* path, and no key travels either. What a replay DOES do is arm the agent's schedules again
|
|
730
|
+
* (#214) — that is idempotent, and it is the repair for a create whose arming failed.
|
|
731
|
+
*
|
|
732
|
+
* ⚠️ Gate is asked BEFORE anything is written, and that ordering IS the answer to "what if Gate
|
|
733
|
+
* is down". Nothing exists yet at that moment, so a Gate that does not answer leaves no node, no
|
|
734
|
+
* version, no R2 object and no audit event — the agent simply does not come into being, and the
|
|
735
|
+
* caller repeats the request. The alternative, a node that exists without a principal plus a
|
|
736
|
+
* way to fill it in later, buys nothing: such an agent starts no run (`agent_key_missing` in the
|
|
737
|
+
* runtime), so it would be a broken row waiting for a second, easily forgotten step — and
|
|
738
|
+
* "somebody must remember to do it in Gate" is the exact chore this ticket removes.
|
|
739
|
+
*
|
|
740
|
+
* ⚠️ If the node write fails after Gate succeeded, the fresh Application is switched off again,
|
|
741
|
+
* best effort. A live principal nobody references is precisely the invisible access #182 exists
|
|
742
|
+
* to prevent, and disabling rather than deleting keeps the compensation consistent with what
|
|
743
|
+
* archiving does — Gate's `DELETE` would take the identity with it (`anchrd/gate#224`).
|
|
744
|
+
*/
|
|
745
|
+
async createAgent(actor, input, caller) {
|
|
746
|
+
// Before anything else — before the idempotency read, before Gate (#190). An installation
|
|
747
|
+
// without a runtime refuses the agent by name, and the refusal costs no storage read, mints
|
|
748
|
+
// no principal, and is the same on every surface because it lives here.
|
|
749
|
+
if (!deps.agentRuntimeAvailable()) {
|
|
750
|
+
throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so agents cannot be created");
|
|
751
|
+
}
|
|
752
|
+
const definitionKey = `${input.idempotencyKey}:definition`;
|
|
753
|
+
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.create", input.idempotencyKey);
|
|
754
|
+
if (existingId) {
|
|
755
|
+
const existing = await agentOf(await requireVisible(actor, existingId));
|
|
756
|
+
// ⚠️ A replay arms as well, and that is what makes a create whose sync failed repairable
|
|
757
|
+
// (#214): repeating it with the same key mints nothing, returns this agent, and puts its
|
|
758
|
+
// alarm right. Without it the only repair left would be a fresh key — a second agent.
|
|
759
|
+
if ((existing.definition?.schedules.length ?? 0) > 0) {
|
|
760
|
+
await deps.agentSchedules.sync({ token: caller.token, agentId: existing.node.id });
|
|
761
|
+
}
|
|
762
|
+
return existing;
|
|
763
|
+
}
|
|
764
|
+
if (input.parentId !== null && !(await deps.repository.can(actor, input.parentId, "write"))) {
|
|
765
|
+
throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
|
|
766
|
+
}
|
|
767
|
+
// Before Gate, for the same reason the runtime check is before Gate: a delegation the saver
|
|
768
|
+
// cannot back must not leave a machine principal behind for an agent that was never created.
|
|
769
|
+
const definition = await delegationOf(actor, input.definition);
|
|
770
|
+
const nodeId = deps.id();
|
|
771
|
+
// The Application's name is what a person reads in Gate's list, so it has to be enough to
|
|
772
|
+
// recognise the agent by. Title alone would leave two agents called "Research" indis-
|
|
773
|
+
// tinguishable; the node ID is what the runtime's key map is keyed by anyway.
|
|
774
|
+
const application = await deps.applications.create({
|
|
775
|
+
token: caller.token,
|
|
776
|
+
name: `Intel agent ${input.title} (${nodeId})`,
|
|
777
|
+
});
|
|
778
|
+
// ⚠️ The handover happens BEFORE the node is written, for the same reason Gate is asked before
|
|
779
|
+
// it: at this moment nothing exists on Intel's side, so a runtime that does not take the key
|
|
780
|
+
// leaves no node, no version, no R2 object and no audit event — the agent simply does not come
|
|
781
|
+
// into being and the caller repeats the request. The other order would produce exactly the
|
|
782
|
+
// agent #200 is about: a node that looks finished and dies at its first run. What it can leave
|
|
783
|
+
// behind is a Durable Object holding a key for a node id that will never exist, which is
|
|
784
|
+
// ciphertext nobody can address — ULIDs are not reused.
|
|
785
|
+
try {
|
|
786
|
+
await deps.agentKeys.store({ token: caller.token, agentId: nodeId, key: application.key });
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
await deps.applications
|
|
790
|
+
.setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
|
|
791
|
+
.catch(() => undefined);
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
794
|
+
const timestamp = deps.now().toISOString();
|
|
795
|
+
let node;
|
|
796
|
+
try {
|
|
797
|
+
node = await deps.repository.insertNode({
|
|
798
|
+
node: {
|
|
799
|
+
id: nodeId,
|
|
800
|
+
parentId: input.parentId,
|
|
801
|
+
kind: "agent",
|
|
802
|
+
title: input.title,
|
|
803
|
+
description: input.description,
|
|
804
|
+
ownerId: actor.id,
|
|
805
|
+
currentVersionId: null,
|
|
806
|
+
createdAt: timestamp,
|
|
807
|
+
updatedAt: timestamp,
|
|
808
|
+
archivedAt: null,
|
|
809
|
+
},
|
|
810
|
+
actorId: actor.id,
|
|
811
|
+
idempotencyKey: input.idempotencyKey,
|
|
812
|
+
auditId: deps.id(),
|
|
813
|
+
application: { id: application.id },
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
await deps.applications
|
|
818
|
+
.setEnabled({ token: caller.token, applicationId: application.id, enabled: false })
|
|
819
|
+
.catch(() => undefined);
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
// ⚠️ No key in the answer, and no field one could travel in (D29). `application.key` has been
|
|
823
|
+
// in one local variable since Gate returned it, went into the runtime, and is out of reach of
|
|
824
|
+
// every surface above this line.
|
|
825
|
+
const created = (await writeAgentVersion(actor, node, definition, null, definitionKey));
|
|
826
|
+
/**
|
|
827
|
+
* ⚠️ Only when this definition actually schedules something — unlike the save below, which
|
|
828
|
+
* arms unconditionally. A brand-new agent has no earlier definition, so there is no alarm that
|
|
829
|
+
* could be left standing: no schedules means there is nothing to arm and nothing to clear, and
|
|
830
|
+
* the screen creates every agent that way (the profile is where schedules are added).
|
|
831
|
+
*
|
|
832
|
+
* ⚠️ It fails loudly, and the agent stays. Everything is written by now and nothing here can
|
|
833
|
+
* roll an agent back, so the caller is told the one thing they can act on: the schedules are
|
|
834
|
+
* not armed. The repair is repeating the create with the SAME idempotency key — which returns
|
|
835
|
+
* this agent, mints no second Application, and arms it. A fresh key would create a second
|
|
836
|
+
* agent beside this one, which is why the message says "again" and not "retry".
|
|
837
|
+
*/
|
|
838
|
+
if (definition.schedules.length > 0) {
|
|
839
|
+
await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
|
|
840
|
+
}
|
|
841
|
+
return created;
|
|
842
|
+
},
|
|
843
|
+
/**
|
|
844
|
+
* A new key for the Application this agent runs as, handed straight to the runtime (D29, #207).
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ A write on the agent node, not a run: `knowledge/write` at the door and the resource ACL
|
|
847
|
+
* here. Whoever may change what an agent does may change which key it does it with; being
|
|
848
|
+
* allowed to press "Run now" says nothing about being allowed to decide its principal.
|
|
849
|
+
*
|
|
850
|
+
* ⚠️ Gate first, runtime second, and nothing in between. Gate issues the replacement before it
|
|
851
|
+
* revokes the old one, so a rotation that fails at Gate changes nothing at all; one that fails
|
|
852
|
+
* at the handover leaves the agent with a key it no longer has — named, repairable, and fixed by
|
|
853
|
+
* rotating again. The alternative order cannot exist: the runtime has nothing to store until
|
|
854
|
+
* Gate has answered.
|
|
855
|
+
*
|
|
856
|
+
* ⚠️ The key is in one local variable and in no answer. This is the operation that exists BECAUSE
|
|
857
|
+
* Gate hands a key out exactly once — reading one back is not something any repair can do.
|
|
858
|
+
*/
|
|
859
|
+
async rotateAgentKey(actor, input, caller) {
|
|
860
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
861
|
+
if (node.kind !== "agent") {
|
|
862
|
+
throw new IntelError(409, "not_an_agent", "Only agents have an application key");
|
|
863
|
+
}
|
|
864
|
+
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
865
|
+
throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
|
|
866
|
+
}
|
|
867
|
+
if (!deps.agentRuntimeAvailable()) {
|
|
868
|
+
throw new IntelError(503, "agent_runtime_not_configured", "This installation has no agent runtime, so there is nothing to give a key to");
|
|
869
|
+
}
|
|
870
|
+
const applicationId = await deps.repository.agentApplicationId(node.id);
|
|
871
|
+
if (applicationId === null) {
|
|
872
|
+
// An agent written before #182, restored from a bundle, or imported. Giving it a principal
|
|
873
|
+
// is an act in Gate, and inventing one here would make Intel the author of a machine
|
|
874
|
+
// account nobody asked for.
|
|
875
|
+
throw new IntelError(409, "agent_application_missing", "This agent has no Gate application, so there is no key to replace");
|
|
876
|
+
}
|
|
877
|
+
const rotated = await deps.applications.rotateKey({ token: caller.token, applicationId });
|
|
878
|
+
await deps.agentKeys.store({ token: caller.token, agentId: node.id, key: rotated.key });
|
|
879
|
+
const rotatedAt = deps.now().toISOString();
|
|
880
|
+
// ⚠️ Written LAST, and only once both sides took the change. An event ahead of the runtime
|
|
881
|
+
// would record a rotation that may not have arrived, and this row is the only trace the act
|
|
882
|
+
// leaves in Intel — nothing about the node itself changes, so a reader who diffed versions
|
|
883
|
+
// would see an agent that silently began acting with a different credential.
|
|
884
|
+
await deps.repository.recordAgentKeyRotation({
|
|
885
|
+
auditId: deps.id(),
|
|
886
|
+
actorId: actor.id,
|
|
887
|
+
nodeId: node.id,
|
|
888
|
+
applicationId,
|
|
889
|
+
occurredAt: rotatedAt,
|
|
890
|
+
});
|
|
891
|
+
return { nodeId: node.id, applicationId, rotatedAt };
|
|
892
|
+
},
|
|
893
|
+
/**
|
|
894
|
+
* A new definition version, and the alarm that has to match it (#214).
|
|
895
|
+
*
|
|
896
|
+
* ⚠️ The runtime is told to re-arm after EVERY definition write, not only after one that names a
|
|
897
|
+
* schedule. The alternative — comparing against the definition that was there before — needs the
|
|
898
|
+
* previous document read back out of R2, and it is wrong in exactly the direction that matters:
|
|
899
|
+
* a save that removes the LAST schedule has nothing to compare against in the new definition and
|
|
900
|
+
* is precisely the write that has to clear the alarm. One binding call per save buys the rule
|
|
901
|
+
* "what is written is what is armed" without a comparison anybody could get backwards.
|
|
902
|
+
*
|
|
903
|
+
* ⚠️ Written first, armed second, and the definition STAYS when the arming fails. It has to: the
|
|
904
|
+
* runtime reads the definition back out of Intel to arm it, so there is no order in which it
|
|
905
|
+
* could be armed before it exists. What the caller gets instead of silence is
|
|
906
|
+
* `agent_schedules_not_armed` — the version is in the history, the profile shows it, and saving
|
|
907
|
+
* again arms it. Rolling the version back would be worse than the unarmed alarm: it would throw
|
|
908
|
+
* away an edit somebody made because a Worker was briefly unreachable.
|
|
909
|
+
*/
|
|
910
|
+
async saveAgentDefinition(actor, input, caller) {
|
|
911
|
+
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
912
|
+
if (existingId)
|
|
913
|
+
return await agentOf(await requireVisible(actor, input.nodeId));
|
|
914
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
915
|
+
if (node.kind !== "agent") {
|
|
916
|
+
throw new IntelError(409, "not_an_agent", "Only agents accept a definition");
|
|
917
|
+
}
|
|
918
|
+
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
919
|
+
throw new IntelError(403, "node_forbidden", "This agent cannot be edited");
|
|
920
|
+
}
|
|
921
|
+
if (node.currentVersionId !== input.baseVersionId) {
|
|
922
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
923
|
+
}
|
|
924
|
+
const saved = await writeAgentVersion(actor, node, await delegationOf(actor, input.definition), input.baseVersionId, input.idempotencyKey);
|
|
925
|
+
await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
|
|
926
|
+
return saved;
|
|
927
|
+
},
|
|
928
|
+
async saveAttachment(actor, input) {
|
|
929
|
+
const existingId = await deps.repository.findIdempotentNode(actor.id, "node.save", input.idempotencyKey);
|
|
930
|
+
if (existingId) {
|
|
931
|
+
const document = await getDocument(await requireVisible(actor, input.nodeId));
|
|
932
|
+
await deps.indexing.enqueue(existingId);
|
|
933
|
+
return document;
|
|
934
|
+
}
|
|
935
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
936
|
+
if (node.kind !== "attachment") {
|
|
937
|
+
throw new IntelError(409, "not_an_attachment", "Only attachment nodes accept file uploads");
|
|
938
|
+
}
|
|
939
|
+
if (!(await deps.repository.can(actor, node.id, "write"))) {
|
|
940
|
+
throw new IntelError(403, "node_forbidden", "Attachment cannot be edited");
|
|
941
|
+
}
|
|
942
|
+
if (node.currentVersionId !== input.baseVersionId) {
|
|
943
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
944
|
+
}
|
|
945
|
+
const bytes = decodeBase64(input.contentBase64);
|
|
946
|
+
const versionId = deps.id();
|
|
947
|
+
const contentKey = contentKeyFor(node.id, versionId);
|
|
948
|
+
const createdAt = deps.now().toISOString();
|
|
949
|
+
const version = {
|
|
950
|
+
id: versionId,
|
|
951
|
+
nodeId: node.id,
|
|
952
|
+
sequence: await nextSequence(node),
|
|
953
|
+
contentKey,
|
|
954
|
+
mediaType: input.mediaType,
|
|
955
|
+
contentHash: await deps.hash(bytes),
|
|
956
|
+
size: bytes.byteLength,
|
|
957
|
+
segment: null,
|
|
958
|
+
createdBy: actor.id,
|
|
959
|
+
createdAt,
|
|
960
|
+
};
|
|
961
|
+
await deps.content.putBytes(contentKey, bytes.buffer, input.mediaType);
|
|
962
|
+
let saved;
|
|
963
|
+
try {
|
|
964
|
+
saved = await deps.repository.appendVersion({
|
|
965
|
+
version,
|
|
966
|
+
actorId: actor.id,
|
|
967
|
+
baseVersionId: input.baseVersionId,
|
|
968
|
+
idempotencyKey: input.idempotencyKey,
|
|
969
|
+
auditId: deps.id(),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
await deps.content.delete(contentKey).catch(() => undefined);
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
if (saved === "conflict") {
|
|
977
|
+
await deps.content.delete(contentKey);
|
|
978
|
+
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
979
|
+
}
|
|
980
|
+
await deps.indexing.enqueue(version.id);
|
|
981
|
+
return { node: await requireVisible(actor, node.id), version, content: null };
|
|
982
|
+
},
|
|
983
|
+
async getAttachment(actor, nodeId) {
|
|
984
|
+
return await attachment(actor, nodeId);
|
|
985
|
+
},
|
|
986
|
+
async readAttachment(actor, nodeId) {
|
|
987
|
+
const metadata = await attachment(actor, nodeId);
|
|
988
|
+
const body = await deps.content.getStream(metadata.version.contentKey);
|
|
989
|
+
if (body === null)
|
|
990
|
+
throw new IntelError(500, "content_missing", "Attachment is missing");
|
|
991
|
+
return { attachment: metadata, body };
|
|
992
|
+
},
|
|
993
|
+
async getTable(actor, nodeId) {
|
|
994
|
+
const node = await requireVisible(actor, nodeId);
|
|
995
|
+
if (node.kind !== "table") {
|
|
996
|
+
throw new IntelError(404, "table_not_found", "Table was not found");
|
|
997
|
+
}
|
|
998
|
+
return await tableOf(node);
|
|
999
|
+
},
|
|
1000
|
+
/**
|
|
1001
|
+
* Writes the header, once. The columns are the contract every later append is measured against
|
|
1002
|
+
* (#40), so a second definition is refused rather than merged: a table whose header changed
|
|
1003
|
+
* would reinterpret every row already appended under the old one, silently and irreversibly.
|
|
1004
|
+
*/
|
|
1005
|
+
async defineTable(actor, input) {
|
|
1006
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
|
|
1007
|
+
const node = await requireTable(actor, input.nodeId);
|
|
1008
|
+
if (replayedId)
|
|
1009
|
+
return await tableOf(node);
|
|
1010
|
+
if (node.currentVersionId !== null) {
|
|
1011
|
+
throw new IntelError(409, "table_already_defined", "This table already has a header");
|
|
1012
|
+
}
|
|
1013
|
+
// The definition is the first snapshot (#135): the complete state of a table with no rows
|
|
1014
|
+
// yet, and the anchor "read from the newest snapshot" starts from.
|
|
1015
|
+
const body = encodeCsv([input.columns]);
|
|
1016
|
+
const version = await writeTableSegment(actor, node, body, "snapshot", input.idempotencyKey);
|
|
1017
|
+
await deps.indexing.enqueue(version.id);
|
|
1018
|
+
return await tableOf({ ...node, currentVersionId: version.id });
|
|
1019
|
+
},
|
|
1020
|
+
/**
|
|
1021
|
+
* Rows at the end, and nothing else touched.
|
|
1022
|
+
*
|
|
1023
|
+
* ⚠️ No `baseVersionId` and no conflict: each append writes its own immutable object and its
|
|
1024
|
+
* own version row, so two appends that arrive together both land and neither can overwrite the
|
|
1025
|
+
* other. This is the whole difference to `save`, which replaces content and therefore has to
|
|
1026
|
+
* know what it replaces.
|
|
1027
|
+
*/
|
|
1028
|
+
async appendTableRows(actor, input) {
|
|
1029
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.append", input.idempotencyKey);
|
|
1030
|
+
const node = await requireTable(actor, input.nodeId);
|
|
1031
|
+
if (replayedId) {
|
|
1032
|
+
const replayed = await deps.repository.getVersion(replayedId);
|
|
1033
|
+
if (replayed) {
|
|
1034
|
+
return {
|
|
1035
|
+
node: await requireVisible(actor, node.id),
|
|
1036
|
+
version: replayed,
|
|
1037
|
+
appended: input.rows.length,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const header = await tableHeader(node);
|
|
1042
|
+
if (header === null) {
|
|
1043
|
+
throw new IntelError(409, "table_undefined", "This table has no header yet; define its columns before appending");
|
|
1044
|
+
}
|
|
1045
|
+
// ⚠️ Refused, never padded and never truncated — see `requireRowShape`.
|
|
1046
|
+
requireRowShape(input.rows, header);
|
|
1047
|
+
const version = await writeTableSegment(actor, node, encodeCsv(input.rows), "append", input.idempotencyKey);
|
|
1048
|
+
await deps.indexing.enqueue(version.id);
|
|
1049
|
+
return {
|
|
1050
|
+
node: await requireVisible(actor, node.id),
|
|
1051
|
+
version,
|
|
1052
|
+
appended: input.rows.length,
|
|
1053
|
+
};
|
|
1054
|
+
},
|
|
1055
|
+
/**
|
|
1056
|
+
* Rows replaced in place (#135). Position is the address — rows carry no identity on purpose —
|
|
1057
|
+
* so the write is guarded the way `save` guards a document: against the version the caller
|
|
1058
|
+
* read, and a table that moved on answers `version_conflict` rather than editing rows the
|
|
1059
|
+
* positions no longer mean.
|
|
1060
|
+
*/
|
|
1061
|
+
async updateTableRows(actor, input) {
|
|
1062
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_update", input.idempotencyKey);
|
|
1063
|
+
const node = await requireTable(actor, input.nodeId);
|
|
1064
|
+
if (replayedId) {
|
|
1065
|
+
const replayed = await deps.repository.getVersion(replayedId);
|
|
1066
|
+
if (replayed) {
|
|
1067
|
+
return {
|
|
1068
|
+
node: await requireVisible(actor, node.id),
|
|
1069
|
+
version: replayed,
|
|
1070
|
+
updated: input.updates.length,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const { header, rows } = await tableStateFor(node, input.baseVersionId);
|
|
1075
|
+
requireRowShape(input.updates.map((update) => update.row), header);
|
|
1076
|
+
requirePositions(input.updates.map((update) => update.position), rows.length);
|
|
1077
|
+
for (const update of input.updates)
|
|
1078
|
+
rows[update.position] = [...update.row];
|
|
1079
|
+
const version = await writeTableSnapshot({
|
|
1080
|
+
actor,
|
|
1081
|
+
node,
|
|
1082
|
+
body: encodeCsv([header, ...rows]),
|
|
1083
|
+
baseVersionId: input.baseVersionId,
|
|
1084
|
+
operation: "node.table_update",
|
|
1085
|
+
metadata: {
|
|
1086
|
+
positions: input.updates.map((update) => update.position),
|
|
1087
|
+
updated: input.updates.length,
|
|
1088
|
+
},
|
|
1089
|
+
idempotencyKey: input.idempotencyKey,
|
|
1090
|
+
});
|
|
1091
|
+
return {
|
|
1092
|
+
node: await requireVisible(actor, node.id),
|
|
1093
|
+
version,
|
|
1094
|
+
updated: input.updates.length,
|
|
1095
|
+
};
|
|
1096
|
+
},
|
|
1097
|
+
async deleteTableRows(actor, input) {
|
|
1098
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_delete", input.idempotencyKey);
|
|
1099
|
+
const node = await requireTable(actor, input.nodeId);
|
|
1100
|
+
if (replayedId) {
|
|
1101
|
+
const replayed = await deps.repository.getVersion(replayedId);
|
|
1102
|
+
if (replayed) {
|
|
1103
|
+
return {
|
|
1104
|
+
node: await requireVisible(actor, node.id),
|
|
1105
|
+
version: replayed,
|
|
1106
|
+
deleted: input.positions.length,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const { header, rows } = await tableStateFor(node, input.baseVersionId);
|
|
1111
|
+
requirePositions(input.positions, rows.length);
|
|
1112
|
+
const removed = new Set(input.positions);
|
|
1113
|
+
const remaining = rows.filter((_row, position) => !removed.has(position));
|
|
1114
|
+
const version = await writeTableSnapshot({
|
|
1115
|
+
actor,
|
|
1116
|
+
node,
|
|
1117
|
+
body: encodeCsv([header, ...remaining]),
|
|
1118
|
+
baseVersionId: input.baseVersionId,
|
|
1119
|
+
operation: "node.table_delete",
|
|
1120
|
+
metadata: { positions: input.positions, deleted: input.positions.length },
|
|
1121
|
+
idempotencyKey: input.idempotencyKey,
|
|
1122
|
+
});
|
|
1123
|
+
return {
|
|
1124
|
+
node: await requireVisible(actor, node.id),
|
|
1125
|
+
version,
|
|
1126
|
+
deleted: input.positions.length,
|
|
1127
|
+
};
|
|
1128
|
+
},
|
|
1129
|
+
/**
|
|
1130
|
+
* A new header over the stored rows, through the explicit mapping and only through it (#135).
|
|
1131
|
+
* The blind second definition stays refused in `defineTable` — this is the deliberate opposite:
|
|
1132
|
+
* every new column names the current column that fills it, or names none and starts empty, and
|
|
1133
|
+
* a current column no entry names is removed together with its cells.
|
|
1134
|
+
*/
|
|
1135
|
+
async redefineTable(actor, input) {
|
|
1136
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.table_redefine", input.idempotencyKey);
|
|
1137
|
+
const node = await requireTable(actor, input.nodeId);
|
|
1138
|
+
if (replayedId)
|
|
1139
|
+
return await tableOf(node);
|
|
1140
|
+
const { header, rows } = await tableStateFor(node, input.baseVersionId);
|
|
1141
|
+
// ⚠️ Sources are matched against the header as it is stored, exactly. A forgiving match would
|
|
1142
|
+
// make "which column did this take" depend on rules nobody can read off the table.
|
|
1143
|
+
const sourceIndex = new Map(header.map((column, index) => [column, index]));
|
|
1144
|
+
for (const column of input.columns) {
|
|
1145
|
+
if (column.source !== null && !sourceIndex.has(column.source)) {
|
|
1146
|
+
throw new IntelError(400, "table_column_unknown", `Column ${column.source} does not exist: the table has ${header.join(", ")}`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const remapped = rows.map((row) => input.columns.map((column) => {
|
|
1150
|
+
if (column.source === null)
|
|
1151
|
+
return "";
|
|
1152
|
+
const index = sourceIndex.get(column.source);
|
|
1153
|
+
return index === undefined ? "" : (row[index] ?? "");
|
|
1154
|
+
}));
|
|
1155
|
+
await writeTableSnapshot({
|
|
1156
|
+
actor,
|
|
1157
|
+
node,
|
|
1158
|
+
body: encodeCsv([input.columns.map((column) => column.name), ...remapped]),
|
|
1159
|
+
baseVersionId: input.baseVersionId,
|
|
1160
|
+
operation: "node.table_redefine",
|
|
1161
|
+
metadata: { columns: input.columns },
|
|
1162
|
+
idempotencyKey: input.idempotencyKey,
|
|
1163
|
+
});
|
|
1164
|
+
return await tableOf(await requireVisible(actor, node.id));
|
|
1165
|
+
},
|
|
1166
|
+
async listVersions(actor, nodeId) {
|
|
1167
|
+
await requireVisible(actor, nodeId);
|
|
1168
|
+
return { items: await deps.repository.listVersions(nodeId) };
|
|
1169
|
+
},
|
|
1170
|
+
async update(actor, input) {
|
|
1171
|
+
const current = await requireVisible(actor, input.nodeId);
|
|
1172
|
+
if (!(await deps.repository.can(actor, current.id, "write"))) {
|
|
1173
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
1174
|
+
}
|
|
1175
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.update", input.idempotencyKey);
|
|
1176
|
+
if (replayedId)
|
|
1177
|
+
return await requireVisible(actor, replayedId);
|
|
1178
|
+
if (input.parentId !== undefined && input.parentId !== null) {
|
|
1179
|
+
if (input.parentId === current.id) {
|
|
1180
|
+
throw new IntelError(409, "move_cycle", "A node cannot contain itself");
|
|
1181
|
+
}
|
|
1182
|
+
const parent = await requireVisible(actor, input.parentId);
|
|
1183
|
+
if (parent.kind !== "folder") {
|
|
1184
|
+
throw new IntelError(409, "parent_not_folder", "A node's parent must be a folder");
|
|
1185
|
+
}
|
|
1186
|
+
if (!(await deps.repository.can(actor, parent.id, "write"))) {
|
|
1187
|
+
throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const updatedAt = deps.now().toISOString();
|
|
1191
|
+
const updated = await deps.repository.updateNode({
|
|
1192
|
+
node: {
|
|
1193
|
+
...current,
|
|
1194
|
+
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
1195
|
+
title: input.title ?? current.title,
|
|
1196
|
+
description: input.description === undefined ? current.description : input.description,
|
|
1197
|
+
updatedAt,
|
|
1198
|
+
},
|
|
1199
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
1200
|
+
actorId: actor.id,
|
|
1201
|
+
idempotencyKey: input.idempotencyKey,
|
|
1202
|
+
auditId: deps.id(),
|
|
1203
|
+
});
|
|
1204
|
+
if (updated === "cycle") {
|
|
1205
|
+
throw new IntelError(409, "move_cycle", "A node cannot be moved into its descendant");
|
|
1206
|
+
}
|
|
1207
|
+
if (updated === "conflict") {
|
|
1208
|
+
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
1209
|
+
}
|
|
1210
|
+
if (updated.currentVersionId)
|
|
1211
|
+
await deps.indexing.enqueue(updated.currentVersionId);
|
|
1212
|
+
return updated;
|
|
1213
|
+
},
|
|
1214
|
+
/**
|
|
1215
|
+
* Archiving and restoring — and for an agent, its Gate Application with it (#182).
|
|
1216
|
+
*
|
|
1217
|
+
* ⚠️ Gate is switched BEFORE the node row, and the order is the whole safety argument. The state
|
|
1218
|
+
* that must not exist is "archived agent, live principal": a machine account that still holds
|
|
1219
|
+
* every grant the agent had, on a node nobody looks at any more. Writing the row first and then
|
|
1220
|
+
* failing at Gate produces exactly that. The reverse leaves "live agent, switched-off
|
|
1221
|
+
* principal" — the agent refuses its next run by name (`agent_principal_rejected` in the
|
|
1222
|
+
* runtime), which somebody notices the same day and which repeating the call repairs.
|
|
1223
|
+
*
|
|
1224
|
+
* ⚠️ The optimistic check is made twice on purpose: cheaply here so a stale `baseUpdatedAt`
|
|
1225
|
+
* never reaches Gate, and authoritatively in the statement that writes. Only a genuine race
|
|
1226
|
+
* gets past the first, and that path puts Gate back the way it was before refusing.
|
|
1227
|
+
*
|
|
1228
|
+
* ⚠️ Gate is asked even when the node is already in the requested state. Both routes are
|
|
1229
|
+
* idempotent there, and it is what makes a repeated call heal a run that died between the two
|
|
1230
|
+
* writes — the alternative would be an agent stuck live because its node already said archived.
|
|
1231
|
+
*
|
|
1232
|
+
* ⚠️ Nothing here is skipped for an agent WITHOUT an Application. There is no principal to
|
|
1233
|
+
* switch, Gate is not called, and the node archives like a document — the case of an agent
|
|
1234
|
+
* imported, restored from a bundle, or written before #182.
|
|
1235
|
+
*/
|
|
1236
|
+
async archive(actor, input, caller) {
|
|
1237
|
+
const current = await requireVisible(actor, input.nodeId);
|
|
1238
|
+
if (!(await deps.repository.can(actor, current.id, "write"))) {
|
|
1239
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
1240
|
+
}
|
|
1241
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.archive", input.idempotencyKey);
|
|
1242
|
+
// A replay switches nothing a second time: the first run already did, and Gate must not learn
|
|
1243
|
+
// about a request that is not happening.
|
|
1244
|
+
if (replayedId)
|
|
1245
|
+
return await requireVisible(actor, replayedId);
|
|
1246
|
+
const applicationId = current.kind === "agent" ? await deps.repository.agentApplicationId(current.id) : null;
|
|
1247
|
+
if (applicationId !== null) {
|
|
1248
|
+
if (current.updatedAt !== input.baseUpdatedAt) {
|
|
1249
|
+
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
1250
|
+
}
|
|
1251
|
+
await deps.applications.setEnabled({
|
|
1252
|
+
token: caller.token,
|
|
1253
|
+
applicationId,
|
|
1254
|
+
enabled: !input.archived,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
const updatedAt = deps.now().toISOString();
|
|
1258
|
+
const updated = await deps.repository.archiveNode({
|
|
1259
|
+
nodeId: current.id,
|
|
1260
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
1261
|
+
archivedAt: input.archived ? updatedAt : null,
|
|
1262
|
+
updatedAt,
|
|
1263
|
+
actorId: actor.id,
|
|
1264
|
+
idempotencyKey: input.idempotencyKey,
|
|
1265
|
+
auditId: deps.id(),
|
|
1266
|
+
});
|
|
1267
|
+
if (updated === "conflict") {
|
|
1268
|
+
if (applicationId !== null) {
|
|
1269
|
+
// Back to what the node actually said, not to the inverse of what was asked: repeating an
|
|
1270
|
+
// archive on an already archived agent would otherwise switch its principal back ON.
|
|
1271
|
+
await deps.applications
|
|
1272
|
+
.setEnabled({
|
|
1273
|
+
token: caller.token,
|
|
1274
|
+
applicationId,
|
|
1275
|
+
enabled: current.archivedAt === null,
|
|
1276
|
+
})
|
|
1277
|
+
.catch(() => undefined);
|
|
1278
|
+
}
|
|
1279
|
+
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
1280
|
+
}
|
|
1281
|
+
return updated;
|
|
1282
|
+
},
|
|
1283
|
+
async listGrants(actor, resourceId) {
|
|
1284
|
+
const node = await requireVisible(actor, resourceId);
|
|
1285
|
+
if (!(await deps.repository.can(actor, resourceId, "share"))) {
|
|
1286
|
+
throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
|
|
1287
|
+
}
|
|
1288
|
+
return {
|
|
1289
|
+
resourceId: node.id,
|
|
1290
|
+
applicableVerbs: applicableVerbs(node.kind),
|
|
1291
|
+
items: await deps.repository.listGrants(resourceId),
|
|
1292
|
+
};
|
|
1293
|
+
},
|
|
1294
|
+
async listLinks(actor, nodeId) {
|
|
1295
|
+
await requireVisible(actor, nodeId);
|
|
1296
|
+
return { items: await deps.repository.listLinksVisible(actor, nodeId) };
|
|
1297
|
+
},
|
|
1298
|
+
/**
|
|
1299
|
+
* The titles of linked documents, for the reader who is looking at the text (#41).
|
|
1300
|
+
*
|
|
1301
|
+
* ⚠️ The one place a document link gets a name, and it answers with what this reader may see —
|
|
1302
|
+
* `getVisible`, the same predicate every other node read goes through — never with a
|
|
1303
|
+
* lookup written specially for a label. What is missing from the answer is missing for two
|
|
1304
|
+
* reasons that must stay indistinguishable: the target is gone, or it is not theirs to see. A
|
|
1305
|
+
* shape that told them apart would let a document confirm the existence of one they may not
|
|
1306
|
+
* reach, and that is what the link does not get to say.
|
|
1307
|
+
*
|
|
1308
|
+
* ⚠️ Archived targets are absent too. A link to a deleted document has to break visibly rather
|
|
1309
|
+
* than point quietly at nothing, and "absent" is what the reader's side draws as broken.
|
|
1310
|
+
*/
|
|
1311
|
+
async resolveLinks(actor, input) {
|
|
1312
|
+
return { items: await deps.repository.resolveVisibleTitles(actor, input.nodeIds) };
|
|
1313
|
+
},
|
|
1314
|
+
async graph(actor, input) {
|
|
1315
|
+
return await deps.repository.graphVisible(actor, input);
|
|
1316
|
+
},
|
|
1317
|
+
// ⚠️ The one shape of this question that answers `null` instead of throwing, and the only one a
|
|
1318
|
+
// graph may be built from. `get` loads the body and turns a refusal into a 404 the caller has to
|
|
1319
|
+
// catch; here "you cannot reach it" is a value, so a drawing can leave a node out rather than
|
|
1320
|
+
// deciding what to do with an exception halfway through (#19).
|
|
1321
|
+
//
|
|
1322
|
+
// ⚠️ Visibility and nothing else — deliberately not "and not archived". A run authorizes its
|
|
1323
|
+
// tree links through this same answer (#20), and archiving a document must not start
|
|
1324
|
+
// refusing steps under a message that names the wrong reason. Callers that must not *draw* an
|
|
1325
|
+
// archived node say so where they draw, the way the callee side already does.
|
|
1326
|
+
async visibleNode(actor, nodeId) {
|
|
1327
|
+
return await deps.repository.getVisible(actor, nodeId);
|
|
1328
|
+
},
|
|
1329
|
+
async share(actor, input) {
|
|
1330
|
+
const node = await requireVisible(actor, input.resourceId);
|
|
1331
|
+
if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
|
|
1332
|
+
throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
|
|
1333
|
+
}
|
|
1334
|
+
if (!applicableVerbs(node.kind).includes(input.verb)) {
|
|
1335
|
+
throw new IntelError(409, "verb_not_applicable", `A ${node.kind} cannot be granted ${input.verb}`);
|
|
1336
|
+
}
|
|
1337
|
+
const replayedId = await deps.repository.findIdempotentNode(actor.id, "node.share", input.idempotencyKey);
|
|
1338
|
+
const principal = input.principal.type === "email"
|
|
1339
|
+
? { type: "email", email: input.principal.email.toLowerCase() }
|
|
1340
|
+
: input.principal;
|
|
1341
|
+
if (replayedId) {
|
|
1342
|
+
const replayed = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === replayedId);
|
|
1343
|
+
// A replay describes the same access as the first attempt did, so the warning is asked
|
|
1344
|
+
// again rather than remembered: whether a document is readable can have changed since.
|
|
1345
|
+
if (replayed) {
|
|
1346
|
+
return {
|
|
1347
|
+
grant: replayed,
|
|
1348
|
+
unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const timestamp = deps.now().toISOString();
|
|
1353
|
+
const grant = await deps.repository.setGrant({
|
|
1354
|
+
grant: {
|
|
1355
|
+
id: deps.id(),
|
|
1356
|
+
resourceId: input.resourceId,
|
|
1357
|
+
principal,
|
|
1358
|
+
verb: input.verb,
|
|
1359
|
+
expiresAt: input.expiresAt,
|
|
1360
|
+
createdBy: actor.id,
|
|
1361
|
+
createdAt: timestamp,
|
|
1362
|
+
},
|
|
1363
|
+
actorId: actor.id,
|
|
1364
|
+
idempotencyKey: input.idempotencyKey,
|
|
1365
|
+
auditId: deps.id(),
|
|
1366
|
+
});
|
|
1367
|
+
// ⚠️ After the grant is written, never before. The answer has to describe the access that is
|
|
1368
|
+
// now in force — sharing `read` on this folder is exactly what makes the documents inside it
|
|
1369
|
+
// readable, and a warning computed a moment earlier would name them all.
|
|
1370
|
+
return { grant, unreadable: await unreadableForPrincipal(actor, node.id, principal) };
|
|
1371
|
+
},
|
|
1372
|
+
async revokeGrant(actor, input) {
|
|
1373
|
+
await requireVisible(actor, input.resourceId);
|
|
1374
|
+
if (!(await deps.repository.can(actor, input.resourceId, "share"))) {
|
|
1375
|
+
throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
|
|
1376
|
+
}
|
|
1377
|
+
const replayed = await deps.repository.findIdempotentRevocation(actor.id, input.idempotencyKey);
|
|
1378
|
+
if (replayed !== null)
|
|
1379
|
+
return { revoked: replayed };
|
|
1380
|
+
// ⚠️ What makes a folder a library is `execute` for the whole organization: that is the one
|
|
1381
|
+
// grant ADR-0004 §3 lets a flow call across a folder edge for. Taking it away while calls
|
|
1382
|
+
// reach in from outside would leave those flows published and unrunnable, so the attempt names
|
|
1383
|
+
// the callers instead of breaking them silently. A grant to one principal beside it narrows
|
|
1384
|
+
// nothing while the organization-wide one stands, so only that one is guarded — and only when
|
|
1385
|
+
// no second organization-wide `execute` above it keeps the reach alive, because then this
|
|
1386
|
+
// revocation narrows nothing either and there is nothing to refuse.
|
|
1387
|
+
const revoked = (await deps.repository.listGrants(input.resourceId)).find((grant) => grant.id === input.grantId);
|
|
1388
|
+
if (revoked?.verb === "execute" &&
|
|
1389
|
+
revoked.principal.type === "organization" &&
|
|
1390
|
+
!(await deps.repository.organizationExecuteReaches(input.resourceId, input.grantId))) {
|
|
1391
|
+
const callers = await deps.externalFlowCallers(actor, input.resourceId);
|
|
1392
|
+
if (callers.visible.length || callers.hidden) {
|
|
1393
|
+
throw new IntelError(409, "folder_execute_in_use", callersDetail(callers));
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return {
|
|
1397
|
+
revoked: await deps.repository.revokeGrant({
|
|
1398
|
+
resourceId: input.resourceId,
|
|
1399
|
+
grantId: input.grantId,
|
|
1400
|
+
actorId: actor.id,
|
|
1401
|
+
idempotencyKey: input.idempotencyKey,
|
|
1402
|
+
auditId: deps.id(),
|
|
1403
|
+
occurredAt: deps.now().toISOString(),
|
|
1404
|
+
}),
|
|
1405
|
+
};
|
|
1406
|
+
},
|
|
1407
|
+
async search(actor, input) {
|
|
1408
|
+
// Authorization before access (ADR-0004 §2): a scope is resolved against the tree and its ACLs
|
|
1409
|
+
// before FTS, the vector index, or any content is touched. A folder the actor may not read and
|
|
1410
|
+
// one that does not exist give the same answer on purpose — `getVisible` cannot tell them
|
|
1411
|
+
// apart, so a scope cannot be used to probe what is filed elsewhere.
|
|
1412
|
+
if (input.scopeId !== undefined) {
|
|
1413
|
+
const scope = await deps.repository.getVisible(actor, input.scopeId);
|
|
1414
|
+
if (!scope || scope.archivedAt) {
|
|
1415
|
+
throw new IntelError(404, "scope_not_found", "Search scope was not found");
|
|
1416
|
+
}
|
|
1417
|
+
if (scope.kind !== "folder") {
|
|
1418
|
+
throw new IntelError(400, "scope_not_folder", "A search scope must be a folder");
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
const lexical = await deps.repository.searchVisible(actor, {
|
|
1422
|
+
...input,
|
|
1423
|
+
limit: Math.min(50, input.limit * 2),
|
|
1424
|
+
});
|
|
1425
|
+
if (!deps.semantic)
|
|
1426
|
+
return { items: lexical.slice(0, input.limit) };
|
|
1427
|
+
try {
|
|
1428
|
+
// ⚠️ The scope narrows the vector hits afterwards, in the same D1 statement that already
|
|
1429
|
+
// re-checks the ACL — so the candidate set has to be wide enough for that cut to leave
|
|
1430
|
+
// something. A folder holding a dozen documents inside a tree of thousands is not reached by
|
|
1431
|
+
// the fan-out an unscoped search gets away with, and a starved scope looks like an empty
|
|
1432
|
+
// folder. 100 is what the port clamps to, so a scoped search asks for all there is.
|
|
1433
|
+
const candidates = input.scopeId === undefined ? Math.min(100, input.limit * 4) : 100;
|
|
1434
|
+
const hits = await deps.semantic.search(input.query, candidates);
|
|
1435
|
+
const semanticScores = new Map();
|
|
1436
|
+
for (const hit of hits) {
|
|
1437
|
+
semanticScores.set(hit.nodeId, Math.max(semanticScores.get(hit.nodeId) ?? 0, hit.score));
|
|
1438
|
+
}
|
|
1439
|
+
const semantic = await deps.repository.hydrateVisibleCitations(actor, [...semanticScores.keys()], input.scopeId);
|
|
1440
|
+
return {
|
|
1441
|
+
items: mergeSearchResults(lexical, semantic, semanticScores, input.limit),
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
return { items: lexical.slice(0, input.limit) };
|
|
1446
|
+
}
|
|
1447
|
+
},
|
|
1448
|
+
async reindex(actor) {
|
|
1449
|
+
if (actor.isAdmin !== true) {
|
|
1450
|
+
throw new IntelError(403, "reindex_forbidden", "Reindex permission is required");
|
|
1451
|
+
}
|
|
1452
|
+
let queued = 0;
|
|
1453
|
+
let after = null;
|
|
1454
|
+
for (;;) {
|
|
1455
|
+
const versionIds = await deps.repository.listCurrentVersionIds({ after, limit: 100 });
|
|
1456
|
+
for (const versionId of versionIds)
|
|
1457
|
+
await deps.indexing.enqueue(versionId);
|
|
1458
|
+
queued += versionIds.length;
|
|
1459
|
+
if (versionIds.length < 100)
|
|
1460
|
+
break;
|
|
1461
|
+
after = versionIds.at(-1) ?? null;
|
|
1462
|
+
}
|
|
1463
|
+
return { queued };
|
|
1464
|
+
},
|
|
1465
|
+
};
|
|
1466
|
+
}
|