@anchrd/intel-api 0.19.0 → 0.20.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 +3 -0
- package/dist/adapters/db/db-flows.js +114 -0
- package/dist/adapters/db/db.js +100 -0
- package/dist/flows/flows.js +52 -0
- package/dist/flows/flows.types.d.ts +24 -0
- package/dist/http/http.js +17 -2
- package/dist/mcp/mcp.js +37 -2
- package/dist/nodes/nodes.js +81 -0
- package/dist/nodes/nodes.types.d.ts +31 -0
- package/package.json +2 -2
|
@@ -117,6 +117,9 @@ export default {
|
|
|
117
117
|
semantic,
|
|
118
118
|
externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
|
|
119
119
|
flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
|
|
120
|
+
// ⚠️ The real one, like its two neighbours: the refusal is only worth anything if it reads
|
|
121
|
+
// real published graphs and the real visibility predicate (#457).
|
|
122
|
+
flowsUsingNode: async (who, nodeId) => await flowRepository.flowsUsingNode({ ...who, canRun: true }, nodeId),
|
|
120
123
|
// ⚠️ `canExecute: true` is not a permission being granted here — the servers list touches
|
|
121
124
|
// nothing and executes nothing. It exists because `ToolActor` carries the flag for the one
|
|
122
125
|
// method that needs it, and a `false` would read as if listing were gated on execution.
|
|
@@ -275,6 +275,120 @@ export function createFlowRepository(deps) {
|
|
|
275
275
|
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
276
276
|
};
|
|
277
277
|
},
|
|
278
|
+
// Who calls THIS flow — the sibling of `externalCallers`, which asks the same question about a
|
|
279
|
+
// whole folder (#457). Same shape: what the actor may see by name, the rest as a number, because
|
|
280
|
+
// a refusal must not become a way of learning that a flow one cannot see exists.
|
|
281
|
+
async flowCallers(actor, flowId) {
|
|
282
|
+
const result = await deps.db
|
|
283
|
+
.prepare(`${subtreeCte}
|
|
284
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
285
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
286
|
+
FROM flows flow
|
|
287
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
288
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
289
|
+
WHERE json_extract(node.value, '$.kind') = 'subflow'
|
|
290
|
+
AND json_extract(node.value, '$.configuration.flowId') = ?
|
|
291
|
+
AND flow.archived_at IS NULL
|
|
292
|
+
AND flow.id <> ?
|
|
293
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
294
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...flowInSubtreeBindings(actor), flowId, flowId)
|
|
295
|
+
.all();
|
|
296
|
+
const rows = result.results ?? [];
|
|
297
|
+
return {
|
|
298
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
299
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
// Published flows whose graph reads THIS node as a step (#457) — the mirror of `nodeReferences`,
|
|
303
|
+
// which asks the same graph the other way round ("which documents do the flows in this folder
|
|
304
|
+
// read"). Same visible/hidden shape as every other refusal: a title only where the actor may see
|
|
305
|
+
// it, the rest counted, so a refusal never becomes a directory of the tree (ADR-0004 §3).
|
|
306
|
+
async flowsUsingNode(actor, nodeId) {
|
|
307
|
+
const result = await deps.db
|
|
308
|
+
.prepare(`${subtreeCte}
|
|
309
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
310
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
311
|
+
FROM flows flow
|
|
312
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
313
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
314
|
+
WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
|
|
315
|
+
AND json_extract(node.value, '$.configuration.resourceId') = ?
|
|
316
|
+
AND flow.archived_at IS NULL
|
|
317
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
318
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...flowInSubtreeBindings(actor), nodeId)
|
|
319
|
+
.all();
|
|
320
|
+
const rows = result.results ?? [];
|
|
321
|
+
return {
|
|
322
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
323
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
// ⚠️ BOTH directions, and the second one was missing (found in review). A run of this flow can be
|
|
327
|
+
// the PARENT of somebody else's run — deleting it takes the record of what started theirs. It can
|
|
328
|
+
// just as well be the CHILD of somebody else's run, and deleting that leaves the caller standing
|
|
329
|
+
// on a sub-flow node whose callee is gone: it can never be completed, and migration 0010's stall
|
|
330
|
+
// rule then sweeps the caller to `failed`. A history with a hole is not a history, in either
|
|
331
|
+
// direction.
|
|
332
|
+
async hasEntangledRuns(flowId) {
|
|
333
|
+
const row = await deps.db
|
|
334
|
+
.prepare(`SELECT 1 AS found
|
|
335
|
+
FROM flow_runs mine
|
|
336
|
+
JOIN flow_runs other
|
|
337
|
+
ON other.parent_run_id = mine.id OR other.id = mine.parent_run_id
|
|
338
|
+
WHERE mine.flow_id = ? AND other.flow_id <> ?
|
|
339
|
+
LIMIT 1`)
|
|
340
|
+
.bind(flowId, flowId)
|
|
341
|
+
.first();
|
|
342
|
+
return row !== null;
|
|
343
|
+
},
|
|
344
|
+
// ⚠️ Archiving deliberately leaves runs in flight alone — restoring the flow is what makes them
|
|
345
|
+
// readable again (`flows.ts`). A purge takes that way back with it, so a run that has not
|
|
346
|
+
// finished refuses the deletion rather than being deleted mid-execution.
|
|
347
|
+
async hasUnfinishedRuns(flowId) {
|
|
348
|
+
const row = await deps.db
|
|
349
|
+
.prepare(`SELECT 1 AS found FROM flow_runs
|
|
350
|
+
WHERE flow_id = ? AND status IN ('queued', 'running', 'waiting') LIMIT 1`)
|
|
351
|
+
.bind(flowId)
|
|
352
|
+
.first();
|
|
353
|
+
return row !== null;
|
|
354
|
+
},
|
|
355
|
+
async purgeFlow(input) {
|
|
356
|
+
const aliveFlow = "EXISTS (SELECT 1 FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL)";
|
|
357
|
+
const deleted = await deps.db.batch([
|
|
358
|
+
// ⚠️ FIRST, reading the title out of the row that falls at the end of this batch. Afterwards
|
|
359
|
+
// nothing can look a name up, and an entry carrying only an id names something nobody can
|
|
360
|
+
// find again (`anchrd/gate#146`).
|
|
361
|
+
deps.db
|
|
362
|
+
.prepare(`INSERT INTO audit_events (
|
|
363
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
364
|
+
) SELECT ?, ?, 'flows.purge', 'flow', f.id, json_object('title', f.title), ?
|
|
365
|
+
FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
|
|
366
|
+
.bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
|
|
367
|
+
// The runs are this flow's history: the steps hang off the runs, the runs off the flow.
|
|
368
|
+
// ⚠️ The guard belongs on EVERY statement, not only the last: a batch whose final statement
|
|
369
|
+
// matches nothing still COMMITS, so a restore in between would empty a living flow's history
|
|
370
|
+
// and answer `409` as if nothing had happened.
|
|
371
|
+
deps.db
|
|
372
|
+
.prepare(`DELETE FROM flow_run_steps
|
|
373
|
+
WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id = ?) AND ${aliveFlow}`)
|
|
374
|
+
.bind(input.flowId, input.flowId),
|
|
375
|
+
deps.db
|
|
376
|
+
.prepare(`DELETE FROM flow_runs WHERE flow_id = ? AND ${aliveFlow}`)
|
|
377
|
+
.bind(input.flowId, input.flowId),
|
|
378
|
+
deps.db
|
|
379
|
+
.prepare(`DELETE FROM flow_versions WHERE flow_id = ? AND ${aliveFlow}`)
|
|
380
|
+
.bind(input.flowId, input.flowId),
|
|
381
|
+
deps.db
|
|
382
|
+
.prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${aliveFlow}`)
|
|
383
|
+
.bind(input.flowId, input.flowId),
|
|
384
|
+
// ⚠️ LAST, and still guarded — a restore between the service's check and this statement
|
|
385
|
+
// would otherwise cost a living flow.
|
|
386
|
+
deps.db
|
|
387
|
+
.prepare("DELETE FROM flows WHERE id = ? AND archived_at IS NOT NULL")
|
|
388
|
+
.bind(input.flowId),
|
|
389
|
+
]);
|
|
390
|
+
return (deleted.at(-1)?.meta?.changes ?? 0) === 0 ? "missing" : { purged: true };
|
|
391
|
+
},
|
|
278
392
|
// The documents the flows in this folder's subtree read, so whoever shares the folder
|
|
279
393
|
// can be told what the grant does not cover (ADR-0004 §4).
|
|
280
394
|
//
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -620,6 +620,106 @@ export function createNodeRepository(deps) {
|
|
|
620
620
|
.first();
|
|
621
621
|
return row ? mapNode(row) : "conflict";
|
|
622
622
|
},
|
|
623
|
+
async hasAnyChild(nodeId) {
|
|
624
|
+
// ⚠️ Without a visibility predicate and without an archived filter, both on purpose: this
|
|
625
|
+
// answers whether the row can be deleted at all, not what somebody may look at (#457).
|
|
626
|
+
//
|
|
627
|
+
// ⚠️ BOTH tables, and that is not defensive breadth: knowledge and flows share ONE tree
|
|
628
|
+
// (ADR-0004), `flows.parent_id` references `nodes(id)` since migration 0002, and a folder whose
|
|
629
|
+
// only content is a flow answered "empty" while it was not. Purging it left the flow with a
|
|
630
|
+
// parent that no longer exists — invisible in the tree, unreachable, unmovable.
|
|
631
|
+
const row = await deps.db
|
|
632
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE parent_id = ?
|
|
633
|
+
UNION ALL
|
|
634
|
+
SELECT 1 AS found FROM flows WHERE parent_id = ?
|
|
635
|
+
LIMIT 1`)
|
|
636
|
+
.bind(nodeId, nodeId)
|
|
637
|
+
.first();
|
|
638
|
+
return row !== null;
|
|
639
|
+
},
|
|
640
|
+
// The names this node's vectors carry in the external index. They are read BEFORE the row falls,
|
|
641
|
+
// because `node_vectors` is the only thing that knows them — and once it is gone, an orphan in
|
|
642
|
+
// Vectorize can never be named again (#457).
|
|
643
|
+
async listVectorKeys(nodeId) {
|
|
644
|
+
const rows = await deps.db
|
|
645
|
+
.prepare("SELECT chunk_key FROM node_vectors WHERE node_id = ?")
|
|
646
|
+
.bind(nodeId)
|
|
647
|
+
.all();
|
|
648
|
+
return (rows.results ?? []).map((row) => row.chunk_key);
|
|
649
|
+
},
|
|
650
|
+
async countInboundLinks(nodeId) {
|
|
651
|
+
const row = await deps.db
|
|
652
|
+
.prepare("SELECT COUNT(*) AS n FROM node_links WHERE target_node_id = ?")
|
|
653
|
+
.bind(nodeId)
|
|
654
|
+
.first();
|
|
655
|
+
return row?.n ?? 0;
|
|
656
|
+
},
|
|
657
|
+
async purgeNode(input) {
|
|
658
|
+
// The keys BEFORE anything falls: after the batch there is no version row left to read them
|
|
659
|
+
// from, and an object nobody can name is an object nobody can delete.
|
|
660
|
+
const keys = await deps.db
|
|
661
|
+
.prepare("SELECT content_key FROM node_versions WHERE node_id = ?")
|
|
662
|
+
.bind(input.nodeId)
|
|
663
|
+
.all();
|
|
664
|
+
// ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
|
|
665
|
+
// `D1.batch` is one implicit transaction, but a final statement that changes ZERO rows is not
|
|
666
|
+
// an error — the transaction commits. With the condition only at the end, a restore between
|
|
667
|
+
// the service's check and this batch deleted every version, grant, link and index row of a
|
|
668
|
+
// node that was alive again, left its row standing, and answered `409 update_conflict`: the
|
|
669
|
+
// reader was told nothing had happened while the content was already gone. The audit row
|
|
670
|
+
// carried the same condition, so it was not even booked.
|
|
671
|
+
const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
|
|
672
|
+
const deleted = await deps.db.batch([
|
|
673
|
+
// ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
|
|
674
|
+
// `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
|
|
675
|
+
// not book one.
|
|
676
|
+
deps.db
|
|
677
|
+
.prepare(`INSERT INTO audit_events (
|
|
678
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
679
|
+
) SELECT ?, ?, 'node.purge', 'node', n.id,
|
|
680
|
+
json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
|
|
681
|
+
FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
682
|
+
.bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
|
|
683
|
+
// The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
|
|
684
|
+
// but a leak: the title stands in the result and the content is gone.
|
|
685
|
+
deps.db
|
|
686
|
+
.prepare(`DELETE FROM node_fts WHERE node_id = ? AND ${alive}`)
|
|
687
|
+
.bind(input.nodeId, input.nodeId),
|
|
688
|
+
deps.db
|
|
689
|
+
.prepare(`DELETE FROM node_vectors WHERE node_id = ? AND ${alive}`)
|
|
690
|
+
.bind(input.nodeId, input.nodeId),
|
|
691
|
+
deps.db
|
|
692
|
+
.prepare(`DELETE FROM node_index_state
|
|
693
|
+
WHERE version_id IN (SELECT id FROM node_versions WHERE node_id = ?) AND ${alive}`)
|
|
694
|
+
.bind(input.nodeId, input.nodeId),
|
|
695
|
+
// Both directions. Outgoing links belong to this node; incoming ones would otherwise point
|
|
696
|
+
// at a row that is gone, and a foreign key would refuse the delete anyway.
|
|
697
|
+
deps.db
|
|
698
|
+
.prepare(`DELETE FROM node_links
|
|
699
|
+
WHERE (source_node_id = ? OR target_node_id = ?) AND ${alive}`)
|
|
700
|
+
.bind(input.nodeId, input.nodeId, input.nodeId),
|
|
701
|
+
// A permission on a node that does not exist is a dangling permission.
|
|
702
|
+
deps.db
|
|
703
|
+
.prepare(`DELETE FROM node_grants WHERE node_id = ? AND ${alive}`)
|
|
704
|
+
.bind(input.nodeId, input.nodeId),
|
|
705
|
+
deps.db
|
|
706
|
+
.prepare(`DELETE FROM node_versions WHERE node_id = ? AND ${alive}`)
|
|
707
|
+
.bind(input.nodeId, input.nodeId),
|
|
708
|
+
// ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
|
|
709
|
+
// one of them would be answered with a resource id that no longer resolves.
|
|
710
|
+
deps.db
|
|
711
|
+
.prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${alive}`)
|
|
712
|
+
.bind(input.nodeId, input.nodeId),
|
|
713
|
+
// LAST — and its count is what the caller reads as the verdict.
|
|
714
|
+
deps.db
|
|
715
|
+
.prepare("DELETE FROM nodes WHERE id = ? AND archived_at IS NOT NULL")
|
|
716
|
+
.bind(input.nodeId),
|
|
717
|
+
]);
|
|
718
|
+
const removed = deleted.at(-1)?.meta?.changes ?? 0;
|
|
719
|
+
if (removed === 0)
|
|
720
|
+
return "missing";
|
|
721
|
+
return { contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))] };
|
|
722
|
+
},
|
|
623
723
|
async appendVersion(input) {
|
|
624
724
|
const version = input.version;
|
|
625
725
|
await deps.db.batch([
|
package/dist/flows/flows.js
CHANGED
|
@@ -270,6 +270,16 @@ function decodeCursor(cursor) {
|
|
|
270
270
|
throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
|
|
271
271
|
return { createdAt, id };
|
|
272
272
|
}
|
|
273
|
+
// ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
|
|
274
|
+
// refusal follows (`callersDetail` in nodes.ts): a refusal must not become a way of learning that a
|
|
275
|
+
// flow one cannot see exists (ADR-0004 §3).
|
|
276
|
+
function callersDetail(callers) {
|
|
277
|
+
const named = callers.visible.map((title) => `“${title}”`).join(", ");
|
|
278
|
+
const rest = callers.hidden === 0
|
|
279
|
+
? ""
|
|
280
|
+
: `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
|
|
281
|
+
return `Published flows still call this one: ${named}${rest}. Change or unpublish them first.`;
|
|
282
|
+
}
|
|
273
283
|
export function createFlows(deps) {
|
|
274
284
|
async function requireFlow(actor, flowId) {
|
|
275
285
|
const flow = await deps.repository.getVisible(actor, flowId);
|
|
@@ -1146,6 +1156,48 @@ export function createFlows(deps) {
|
|
|
1146
1156
|
}
|
|
1147
1157
|
return updated;
|
|
1148
1158
|
},
|
|
1159
|
+
/**
|
|
1160
|
+
* ⚠️ The flow half of #457 — after this a flow is really gone, with its versions and the record
|
|
1161
|
+
* of every run it ever had. Read the decisions on that issue before touching it.
|
|
1162
|
+
*/
|
|
1163
|
+
async purge(actor, input) {
|
|
1164
|
+
const current = await deps.repository.getVisible(actor, input.flowId);
|
|
1165
|
+
if (!current)
|
|
1166
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1167
|
+
if (!(await deps.repository.can(actor, input.flowId, "write"))) {
|
|
1168
|
+
throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
|
|
1169
|
+
}
|
|
1170
|
+
// ⚠️ THE structural guard, same as the node's: a living flow has no path into nothing.
|
|
1171
|
+
if (!current.archivedAt) {
|
|
1172
|
+
throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
|
|
1173
|
+
}
|
|
1174
|
+
// A published caller would break at RUN TIME, in front of somebody who did not order this.
|
|
1175
|
+
const callers = await deps.repository.flowCallers(actor, current.id);
|
|
1176
|
+
if (callers.visible.length || callers.hidden) {
|
|
1177
|
+
throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers));
|
|
1178
|
+
}
|
|
1179
|
+
// ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
|
|
1180
|
+
// belonging to other flows. Deleting them would take the record of who started those runs
|
|
1181
|
+
// with it — a history with a hole is not a history, so this refuses instead.
|
|
1182
|
+
if (await deps.repository.hasEntangledRuns(current.id)) {
|
|
1183
|
+
throw new IntelError(409, "flow_runs_entangled", "Runs of this flow are tied to runs of other flows. Deleting them would leave those without the record of what started them, or without the run they are waiting for.");
|
|
1184
|
+
}
|
|
1185
|
+
// ⚠️ Archiving deliberately leaves a run in flight alone, because restoring the flow makes it
|
|
1186
|
+
// readable again. A purge takes that way back with it — so an unfinished run refuses.
|
|
1187
|
+
if (await deps.repository.hasUnfinishedRuns(current.id)) {
|
|
1188
|
+
throw new IntelError(409, "flow_runs_unfinished", "This flow still has runs that have not finished. Wait for them or cancel them first.");
|
|
1189
|
+
}
|
|
1190
|
+
const purged = await deps.repository.purgeFlow({
|
|
1191
|
+
flowId: current.id,
|
|
1192
|
+
actorId: actor.id,
|
|
1193
|
+
auditId: deps.id(),
|
|
1194
|
+
occurredAt: deps.now().toISOString(),
|
|
1195
|
+
});
|
|
1196
|
+
if (purged === "missing") {
|
|
1197
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
1198
|
+
}
|
|
1199
|
+
return { purged: true, title: current.title };
|
|
1200
|
+
},
|
|
1149
1201
|
/**
|
|
1150
1202
|
* Archive a flow, or take it back out again.
|
|
1151
1203
|
*
|
|
@@ -88,6 +88,24 @@ export interface FlowRepository {
|
|
|
88
88
|
idempotencyKey: string;
|
|
89
89
|
auditId: string;
|
|
90
90
|
}): Promise<"conflict" | Flow>;
|
|
91
|
+
flowsUsingNode(actor: FlowActor, nodeId: string): Promise<{
|
|
92
|
+
visible: string[];
|
|
93
|
+
hidden: number;
|
|
94
|
+
}>;
|
|
95
|
+
flowCallers(actor: FlowActor, flowId: string): Promise<{
|
|
96
|
+
visible: string[];
|
|
97
|
+
hidden: number;
|
|
98
|
+
}>;
|
|
99
|
+
hasEntangledRuns(flowId: string): Promise<boolean>;
|
|
100
|
+
hasUnfinishedRuns(flowId: string): Promise<boolean>;
|
|
101
|
+
purgeFlow(input: {
|
|
102
|
+
flowId: string;
|
|
103
|
+
actorId: string;
|
|
104
|
+
auditId: string;
|
|
105
|
+
occurredAt: string;
|
|
106
|
+
}): Promise<"missing" | {
|
|
107
|
+
purged: true;
|
|
108
|
+
}>;
|
|
91
109
|
getVersion(versionId: string): Promise<FlowVersion | null>;
|
|
92
110
|
listVersions(flowId: string): Promise<Omit<FlowVersionSummary, "published">[]>;
|
|
93
111
|
getVersions(versionIds: string[]): Promise<FlowVersion[]>;
|
|
@@ -213,6 +231,12 @@ export interface FlowService {
|
|
|
213
231
|
create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
|
|
214
232
|
update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
|
|
215
233
|
archive(actor: FlowActor, input: ArchiveFlowInput): Promise<Flow>;
|
|
234
|
+
purge(actor: FlowActor, input: {
|
|
235
|
+
flowId: string;
|
|
236
|
+
}): Promise<{
|
|
237
|
+
purged: true;
|
|
238
|
+
title: string;
|
|
239
|
+
}>;
|
|
216
240
|
save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
217
241
|
previewPublish(actor: FlowActor, input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
218
242
|
publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
|
package/dist/http/http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
1
|
+
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
3
|
-
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
3
|
+
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
4
4
|
import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
5
5
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
6
6
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
@@ -360,6 +360,15 @@ export function createHttp(deps) {
|
|
|
360
360
|
// Application behind an agent (#182). Every other kind never reaches it.
|
|
361
361
|
return context.json(await deps.nodes.archive(asActor(auth), input));
|
|
362
362
|
});
|
|
363
|
+
// ⚠️ DELETE, and it means it (#457). Every other "delete" in this surface is `archived_at`; this
|
|
364
|
+
// one leaves nothing behind. The same capability as archiving — `nodes.write` — because the
|
|
365
|
+
// structural guard is elsewhere: only an archived node can be purged, so the destructive way has
|
|
366
|
+
// two steps and the first one is reversible.
|
|
367
|
+
app.delete("/nodes/:nodeId", async (context) => {
|
|
368
|
+
const auth = requireCapability(context, "nodes", "write");
|
|
369
|
+
const input = PurgeNodeInput.parse({ nodeId: context.req.param("nodeId") });
|
|
370
|
+
return context.json(await deps.nodes.purge(asActor(auth), input));
|
|
371
|
+
});
|
|
363
372
|
app.get("/nodes/:nodeId/grants", async (context) => {
|
|
364
373
|
const auth = requireCapability(context, "nodes", "share");
|
|
365
374
|
return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
|
|
@@ -475,6 +484,12 @@ export function createHttp(deps) {
|
|
|
475
484
|
}
|
|
476
485
|
return context.json(await deps.flows.archive(asFlowActor(auth), input));
|
|
477
486
|
});
|
|
487
|
+
// ⚠️ DELETE, and it means it (#457) — the flow's versions and the record of every run it had.
|
|
488
|
+
app.delete("/flows/:flowId", async (context) => {
|
|
489
|
+
const auth = requireCapability(context, "flows", "write");
|
|
490
|
+
const input = PurgeFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
491
|
+
return context.json(await deps.flows.purge(asFlowActor(auth), input));
|
|
492
|
+
});
|
|
478
493
|
app.post("/flows/:flowId/versions", async (context) => {
|
|
479
494
|
const auth = requireCapability(context, "flows", "write");
|
|
480
495
|
const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
|
|
2
|
-
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
2
|
+
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
3
3
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
4
|
-
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
4
|
+
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
5
5
|
import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
6
6
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
7
7
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
@@ -464,6 +464,26 @@ export async function handleMcp(request, deps) {
|
|
|
464
464
|
openWorldHint: false,
|
|
465
465
|
},
|
|
466
466
|
}, async (input) => text(await deps.nodes.archive(actor, input)));
|
|
467
|
+
// ⚠️ It exists on this surface for the reason #436 states: a way only the interface has is a way
|
|
468
|
+
// an agent goes around, by looking for the row in the database. What it may do is bounded by the
|
|
469
|
+
// same rules the interface obeys — only an archived node, only an empty folder, never one a flow
|
|
470
|
+
// still uses.
|
|
471
|
+
server.registerTool("node_purge", {
|
|
472
|
+
title: "Delete node for good",
|
|
473
|
+
description: "Delete one ARCHIVED node and everything belonging to it — every version, its content, its grants and its entries in the search index. This cannot be undone. A living node is refused: archive it first. A folder is refused while it still holds anything, and so is a node a published flow uses as a step; documents that merely LINK to it are not, and their links break.",
|
|
474
|
+
inputSchema: PurgeNodeInput,
|
|
475
|
+
annotations: {
|
|
476
|
+
title: "Delete node for good",
|
|
477
|
+
readOnlyHint: false,
|
|
478
|
+
destructiveHint: true,
|
|
479
|
+
// ⚠️ NOT idempotent, and that is the honest answer rather than a missing flag: calling it
|
|
480
|
+
// twice deletes once and then answers 404. A client that repeated it on a timeout would
|
|
481
|
+
// otherwise read the second answer as "already done" when it may mean "somebody else's
|
|
482
|
+
// node with that id is gone".
|
|
483
|
+
idempotentHint: false,
|
|
484
|
+
openWorldHint: false,
|
|
485
|
+
},
|
|
486
|
+
}, async (input) => text(await deps.nodes.purge(actor, input)));
|
|
467
487
|
}
|
|
468
488
|
if (permits(deps.authorization, "nodes", "share")) {
|
|
469
489
|
server.registerTool("node_grant_list", {
|
|
@@ -689,6 +709,21 @@ export async function handleMcp(request, deps) {
|
|
|
689
709
|
openWorldHint: false,
|
|
690
710
|
},
|
|
691
711
|
}, async (input) => text(await deps.flows.archive(flowActor, input)));
|
|
712
|
+
// ⚠️ On this surface because a way only the UI has is a way an agent works around (#436). Its
|
|
713
|
+
// limits are the same as the UI's.
|
|
714
|
+
server.registerTool("flow_purge", {
|
|
715
|
+
title: "Delete flow for good",
|
|
716
|
+
description: "Delete one ARCHIVED flow together with every version and the record of every run it had. This cannot be undone. A living flow is refused: archive it first. So is a flow another published flow still calls, and one whose runs started runs of other flows.",
|
|
717
|
+
inputSchema: PurgeFlowInput,
|
|
718
|
+
annotations: {
|
|
719
|
+
title: "Delete flow for good",
|
|
720
|
+
readOnlyHint: false,
|
|
721
|
+
destructiveHint: true,
|
|
722
|
+
// Not idempotent, and that is the honest answer: twice deletes once and then answers 404.
|
|
723
|
+
idempotentHint: false,
|
|
724
|
+
openWorldHint: false,
|
|
725
|
+
},
|
|
726
|
+
}, async (input) => text(await deps.flows.purge(flowActor, input)));
|
|
692
727
|
server.registerTool("flow_version_create", {
|
|
693
728
|
title: "Create flow version",
|
|
694
729
|
description: "Append a validated immutable flow graph version with optimistic concurrency.",
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -849,6 +849,87 @@ export function createNodes(deps) {
|
|
|
849
849
|
await deps.indexing.enqueue(updated.currentVersionId);
|
|
850
850
|
return updated;
|
|
851
851
|
},
|
|
852
|
+
/**
|
|
853
|
+
* ⚠️ The one call in Intel after which something is really gone (#457). Read the decisions on
|
|
854
|
+
* that issue before changing anything here: each refusal below is one of them, and each was
|
|
855
|
+
* chosen to the safer side.
|
|
856
|
+
*/
|
|
857
|
+
async purge(actor, input) {
|
|
858
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
859
|
+
if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
|
|
860
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
861
|
+
}
|
|
862
|
+
// ⚠️ THE structural guard: only what is already archived may go. A stronger PERMISSION would
|
|
863
|
+
// not have made this safer, only rarer — whoever may archive can already make the content
|
|
864
|
+
// invisible. What makes it safe is that the destructive path has two steps and the first one
|
|
865
|
+
// is reversible.
|
|
866
|
+
if (!node.archivedAt) {
|
|
867
|
+
throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
|
|
868
|
+
}
|
|
869
|
+
// A folder is refused while anything is still inside — archived rows included. A cascade
|
|
870
|
+
// would turn ONE confirmed click into an unknown number of deletions, and the confirmation
|
|
871
|
+
// could then no longer name what disappears. `folder_execute_in_use` (#448) is the pattern:
|
|
872
|
+
// an answer with a reason beats an effect that reaches further than the question.
|
|
873
|
+
if (await deps.repository.hasAnyChild(node.id)) {
|
|
874
|
+
throw new IntelError(409, "folder_not_empty", "This folder still holds entries. Empty it before deleting it for good.");
|
|
875
|
+
}
|
|
876
|
+
// ⚠️ The other class of dependant, and the reason it refuses where a prose link does not: a
|
|
877
|
+
// flow would break at RUN TIME, in front of somebody who did not order this deletion.
|
|
878
|
+
//
|
|
879
|
+
// ⚠️ `flowsUsingNode`, NOT `externalFlowCallers` — the first version asked the second and
|
|
880
|
+
// therefore never refused anything (found in review). `externalFlowCallers` answers "who calls
|
|
881
|
+
// a FLOW filed inside this folder" (`kind: "subflow"`); a flow reads a NODE through a
|
|
882
|
+
// tree-link step (`configuration.resourceId`). Two different graph shapes, and only the second
|
|
883
|
+
// one is what a deleted document breaks.
|
|
884
|
+
const users = await deps.flowsUsingNode(actor, node.id);
|
|
885
|
+
if (users.visible.length || users.hidden) {
|
|
886
|
+
throw new IntelError(409, "node_in_use_by_flow", callersDetail(users));
|
|
887
|
+
}
|
|
888
|
+
// Not a refusal — a fact for the record. A prose reference is a mention, not an operation;
|
|
889
|
+
// refusing over one would make deleting a lottery, decided by whoever once linked to it. The
|
|
890
|
+
// surface names the number BEFORE the reader says yes.
|
|
891
|
+
const inboundLinks = await deps.repository.countInboundLinks(node.id);
|
|
892
|
+
// ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
|
|
893
|
+
// The comment here used to say Vectorize needed no call because archiving had already emptied
|
|
894
|
+
// it. Archiving only ENQUEUES the purge, and that pass starts from `archivedNodeId(versionId)`
|
|
895
|
+
// — a join over `nodes`. Once this batch has run, that answers `null` and removes nothing,
|
|
896
|
+
// while `node_vectors`, the only record of what the vectors are CALLED, is gone with it. The
|
|
897
|
+
// orphans would then be unnameable forever, and each one keeps a place in the 100-candidate
|
|
898
|
+
// list Vectorize caps per installation.
|
|
899
|
+
//
|
|
900
|
+
// ⚠️ Before the batch rather than after, which inverts the order the content keys follow — and
|
|
901
|
+
// the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
|
|
902
|
+
// vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
|
|
903
|
+
// removal after the batch leaves orphans nothing can name.
|
|
904
|
+
const vectorKeys = await deps.repository.listVectorKeys(node.id);
|
|
905
|
+
// The whole-node chunk carries the empty key and is never in the table — the pass writes it
|
|
906
|
+
// and records the rest, so a purge that only sent the recorded ones would leave it behind.
|
|
907
|
+
if (deps.semantic)
|
|
908
|
+
await deps.semantic.remove(node.id, ["", ...vectorKeys]);
|
|
909
|
+
const purged = await deps.repository.purgeNode({
|
|
910
|
+
nodeId: node.id,
|
|
911
|
+
actorId: actor.id,
|
|
912
|
+
auditId: deps.id(),
|
|
913
|
+
occurredAt: deps.now().toISOString(),
|
|
914
|
+
// ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
|
|
915
|
+
// and this is what travels beside it. After the delete nothing can look either up.
|
|
916
|
+
metadata: { inboundLinks },
|
|
917
|
+
});
|
|
918
|
+
// The row was restored or already gone between the check and the statement. Nothing was
|
|
919
|
+
// deleted, and saying so is better than reporting a success that did not happen.
|
|
920
|
+
if (purged === "missing") {
|
|
921
|
+
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
922
|
+
}
|
|
923
|
+
// ⚠️ AFTER the database agreed, never before: it is the truth about what exists, and an object
|
|
924
|
+
// deleted ahead of a batch that then fails would leave a node whose content is gone.
|
|
925
|
+
//
|
|
926
|
+
// ⚠️ Vectorize needs no call here, and that follows from the guard above rather than from
|
|
927
|
+
// luck: archiving already takes a node's vectors out of the index (see `archive`), and only
|
|
928
|
+
// an archived node reaches this line. What is emptied above is Intel's own bookkeeping.
|
|
929
|
+
for (const key of purged.contentKeys)
|
|
930
|
+
await deps.content.delete(key);
|
|
931
|
+
return { purged: true, title: node.title };
|
|
932
|
+
},
|
|
852
933
|
async listGrants(actor, resourceId) {
|
|
853
934
|
const node = await requireVisible(actor, resourceId);
|
|
854
935
|
if (!(await deps.repository.can(actor, resourceId, "share"))) {
|
|
@@ -84,6 +84,18 @@ export interface NodeRepository {
|
|
|
84
84
|
idempotencyKey: string;
|
|
85
85
|
auditId: string;
|
|
86
86
|
}): Promise<"conflict" | Node>;
|
|
87
|
+
hasAnyChild(nodeId: string): Promise<boolean>;
|
|
88
|
+
listVectorKeys(nodeId: string): Promise<string[]>;
|
|
89
|
+
countInboundLinks(nodeId: string): Promise<number>;
|
|
90
|
+
purgeNode(input: {
|
|
91
|
+
nodeId: string;
|
|
92
|
+
actorId: string;
|
|
93
|
+
auditId: string;
|
|
94
|
+
occurredAt: string;
|
|
95
|
+
metadata: Record<string, unknown>;
|
|
96
|
+
}): Promise<"missing" | {
|
|
97
|
+
contentKeys: string[];
|
|
98
|
+
}>;
|
|
87
99
|
listGrants(resourceId: string): Promise<ResourceGrant[]>;
|
|
88
100
|
organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
|
|
89
101
|
listLinksVisible(actor: Actor, nodeId: string): Promise<NodeLink[]>;
|
|
@@ -189,6 +201,19 @@ export interface NodesDeps {
|
|
|
189
201
|
hidden: number;
|
|
190
202
|
}>;
|
|
191
203
|
flowNodeReferences(actor: Actor, folderId: string): Promise<string[]>;
|
|
204
|
+
/**
|
|
205
|
+
* Published flows that use THIS node as a step (#457).
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ Not the same question as `externalFlowCallers`, and the difference cost a refusal that never
|
|
208
|
+
* fired: that one asks who calls a FLOW filed inside a folder (`kind: "subflow"`,
|
|
209
|
+
* `configuration.flowId`). A flow reads a NODE through a tree-link step (`configuration
|
|
210
|
+
* .resourceId`) — a different graph shape entirely, and the one a deletion breaks at run time, in
|
|
211
|
+
* front of somebody who did not order it.
|
|
212
|
+
*/
|
|
213
|
+
flowsUsingNode(actor: Actor, nodeId: string): Promise<{
|
|
214
|
+
visible: string[];
|
|
215
|
+
hidden: number;
|
|
216
|
+
}>;
|
|
192
217
|
/**
|
|
193
218
|
* The handles of the MCP servers this actor reaches through the portal right now (D30).
|
|
194
219
|
*
|
|
@@ -260,6 +285,12 @@ export interface NodeService {
|
|
|
260
285
|
* is off, which stops working loudly and is repaired by repeating the call.
|
|
261
286
|
*/
|
|
262
287
|
archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
|
|
288
|
+
purge(actor: Actor, input: {
|
|
289
|
+
nodeId: string;
|
|
290
|
+
}): Promise<{
|
|
291
|
+
purged: true;
|
|
292
|
+
title: string;
|
|
293
|
+
}>;
|
|
263
294
|
listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
|
|
264
295
|
listLinks(actor: Actor, nodeId: string): Promise<{
|
|
265
296
|
items: NodeLink[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.15.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.16.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|