@anchrd/intel-api 0.19.0 → 0.21.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 +236 -0
- package/dist/flows/flows.js +52 -0
- package/dist/flows/flows.types.d.ts +24 -0
- package/dist/http/http.js +29 -2
- package/dist/mcp/mcp.js +37 -2
- package/dist/nodes/nodes.js +101 -0
- package/dist/nodes/nodes.types.d.ts +59 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -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,206 @@ 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(`WITH RECURSIVE tree(id) AS (
|
|
653
|
+
SELECT id FROM nodes WHERE id = ?
|
|
654
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
655
|
+
) SELECT COUNT(*) AS n FROM node_links
|
|
656
|
+
WHERE target_node_id IN (SELECT id FROM tree)
|
|
657
|
+
AND source_node_id NOT IN (SELECT id FROM tree)`)
|
|
658
|
+
.bind(nodeId)
|
|
659
|
+
.first();
|
|
660
|
+
return row?.n ?? 0;
|
|
661
|
+
},
|
|
662
|
+
async inspectPurgeTree(nodeId) {
|
|
663
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
664
|
+
SELECT id FROM nodes WHERE id = ?
|
|
665
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
666
|
+
)`;
|
|
667
|
+
const nodes = await deps.db
|
|
668
|
+
.prepare(`${tree} SELECT n.id, n.kind FROM nodes n JOIN tree ON tree.id = n.id`)
|
|
669
|
+
.bind(nodeId)
|
|
670
|
+
.all();
|
|
671
|
+
const nodeIds = (nodes.results ?? []).map((row) => row.id);
|
|
672
|
+
const flows = await deps.db
|
|
673
|
+
.prepare(`${tree} SELECT f.id FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
|
|
674
|
+
.bind(nodeId)
|
|
675
|
+
.all();
|
|
676
|
+
const keys = await deps.db
|
|
677
|
+
.prepare(`${tree} SELECT v.content_key FROM node_versions v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
678
|
+
.bind(nodeId)
|
|
679
|
+
.all();
|
|
680
|
+
const vectors = await deps.db
|
|
681
|
+
.prepare(`${tree} SELECT v.node_id, v.chunk_key FROM node_vectors v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
682
|
+
.bind(nodeId)
|
|
683
|
+
.all();
|
|
684
|
+
const grouped = new Map();
|
|
685
|
+
for (const row of vectors.results ?? []) {
|
|
686
|
+
const current = grouped.get(row.node_id) ?? [];
|
|
687
|
+
current.push(row.chunk_key);
|
|
688
|
+
grouped.set(row.node_id, current);
|
|
689
|
+
}
|
|
690
|
+
const counts = { flow: flows.results?.length ?? 0 };
|
|
691
|
+
for (const row of nodes.results ?? [])
|
|
692
|
+
counts[row.kind] = (counts[row.kind] ?? 0) + 1;
|
|
693
|
+
return {
|
|
694
|
+
nodeIds,
|
|
695
|
+
flowIds: (flows.results ?? []).map((row) => row.id),
|
|
696
|
+
contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
|
|
697
|
+
vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
|
|
698
|
+
counts,
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
async findPurgeReplay(actorId, idempotencyKey) {
|
|
702
|
+
const row = await deps.db
|
|
703
|
+
.prepare(`SELECT title, content_keys_json FROM node_purge_receipts
|
|
704
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
705
|
+
.bind(actorId, idempotencyKey)
|
|
706
|
+
.first();
|
|
707
|
+
return row ? { title: row.title, contentKeys: JSON.parse(row.content_keys_json) } : null;
|
|
708
|
+
},
|
|
709
|
+
async completePurgeReplay(actorId, idempotencyKey, completedAt) {
|
|
710
|
+
await deps.db
|
|
711
|
+
.prepare(`UPDATE node_purge_receipts SET completed_at = ?
|
|
712
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
713
|
+
.bind(completedAt, actorId, idempotencyKey)
|
|
714
|
+
.run();
|
|
715
|
+
},
|
|
716
|
+
async purgeNode(input) {
|
|
717
|
+
// The keys BEFORE anything falls: after the batch there is no version row left to read them
|
|
718
|
+
// from, and an object nobody can name is an object nobody can delete.
|
|
719
|
+
const keys = await deps.db
|
|
720
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
721
|
+
SELECT id FROM nodes WHERE id = ?
|
|
722
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
723
|
+
) SELECT content_key FROM node_versions WHERE node_id IN (SELECT id FROM tree)`)
|
|
724
|
+
.bind(input.nodeId)
|
|
725
|
+
.all();
|
|
726
|
+
// ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
|
|
727
|
+
// `D1.batch` is one implicit transaction, but a final statement that changes ZERO rows is not
|
|
728
|
+
// an error — the transaction commits. With the condition only at the end, a restore between
|
|
729
|
+
// the service's check and this batch deleted every version, grant, link and index row of a
|
|
730
|
+
// node that was alive again, left its row standing, and answered `409 update_conflict`: the
|
|
731
|
+
// reader was told nothing had happened while the content was already gone. The audit row
|
|
732
|
+
// carried the same condition, so it was not even booked.
|
|
733
|
+
const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
|
|
734
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
735
|
+
SELECT id FROM nodes WHERE id = ?
|
|
736
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
737
|
+
)`;
|
|
738
|
+
const treeFlows = `${tree}, tree_flows(id) AS (
|
|
739
|
+
SELECT id FROM flows WHERE parent_id IN (SELECT id FROM tree)
|
|
740
|
+
)`;
|
|
741
|
+
const deleted = await deps.db.batch([
|
|
742
|
+
// ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
|
|
743
|
+
// `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
|
|
744
|
+
// not book one.
|
|
745
|
+
deps.db
|
|
746
|
+
.prepare(`INSERT INTO audit_events (
|
|
747
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
748
|
+
) SELECT ?, ?, 'node.purge', 'node', n.id,
|
|
749
|
+
json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
|
|
750
|
+
FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
751
|
+
.bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
|
|
752
|
+
deps.db
|
|
753
|
+
.prepare(`INSERT INTO node_purge_receipts (
|
|
754
|
+
actor_id, idempotency_key, node_id, title, content_keys_json, completed_at
|
|
755
|
+
) SELECT ?, ?, n.id, n.title, ?, NULL FROM nodes n
|
|
756
|
+
WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
757
|
+
.bind(input.actorId, input.idempotencyKey, JSON.stringify([...new Set((keys.results ?? []).map((row) => row.content_key))]), input.nodeId),
|
|
758
|
+
// The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
|
|
759
|
+
// but a leak: the title stands in the result and the content is gone.
|
|
760
|
+
deps.db
|
|
761
|
+
.prepare(`${tree} DELETE FROM node_fts WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
762
|
+
.bind(input.nodeId, input.nodeId),
|
|
763
|
+
deps.db
|
|
764
|
+
.prepare(`${tree} DELETE FROM node_vectors WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
765
|
+
.bind(input.nodeId, input.nodeId),
|
|
766
|
+
deps.db
|
|
767
|
+
.prepare(`${tree} DELETE FROM node_index_state
|
|
768
|
+
WHERE version_id IN (SELECT id FROM node_versions WHERE node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
769
|
+
.bind(input.nodeId, input.nodeId),
|
|
770
|
+
// Both directions. Outgoing links belong to this node; incoming ones would otherwise point
|
|
771
|
+
// at a row that is gone, and a foreign key would refuse the delete anyway.
|
|
772
|
+
deps.db
|
|
773
|
+
.prepare(`${tree} DELETE FROM node_links
|
|
774
|
+
WHERE (source_node_id IN (SELECT id FROM tree) OR target_node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
775
|
+
.bind(input.nodeId, input.nodeId),
|
|
776
|
+
// A permission on a node that does not exist is a dangling permission.
|
|
777
|
+
deps.db
|
|
778
|
+
.prepare(`${tree} DELETE FROM node_grants WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
779
|
+
.bind(input.nodeId, input.nodeId),
|
|
780
|
+
deps.db
|
|
781
|
+
.prepare(`${treeFlows} UPDATE flows SET published_version_id = NULL
|
|
782
|
+
WHERE id NOT IN (SELECT id FROM tree_flows) AND EXISTS (
|
|
783
|
+
SELECT 1 FROM flow_versions fv, json_tree(fv.graph_json) graph
|
|
784
|
+
WHERE fv.id IN (flows.current_version_id, flows.published_version_id)
|
|
785
|
+
AND ((graph.key = 'resourceId' AND graph.value IN (SELECT id FROM tree))
|
|
786
|
+
OR (graph.key = 'flowId' AND graph.value IN (SELECT id FROM tree_flows)))
|
|
787
|
+
) AND ${alive}`)
|
|
788
|
+
.bind(input.nodeId, input.nodeId),
|
|
789
|
+
deps.db
|
|
790
|
+
.prepare(`${treeFlows} DELETE FROM flow_run_steps
|
|
791
|
+
WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows)) AND ${alive}`)
|
|
792
|
+
.bind(input.nodeId, input.nodeId),
|
|
793
|
+
deps.db
|
|
794
|
+
.prepare(`${treeFlows} DELETE FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
795
|
+
.bind(input.nodeId, input.nodeId),
|
|
796
|
+
deps.db
|
|
797
|
+
.prepare(`${treeFlows} DELETE FROM flow_versions WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
798
|
+
.bind(input.nodeId, input.nodeId),
|
|
799
|
+
deps.db
|
|
800
|
+
.prepare(`${treeFlows} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
801
|
+
.bind(input.nodeId, input.nodeId),
|
|
802
|
+
deps.db
|
|
803
|
+
.prepare(`${treeFlows} DELETE FROM flows WHERE id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
804
|
+
.bind(input.nodeId, input.nodeId),
|
|
805
|
+
deps.db
|
|
806
|
+
.prepare(`${tree} DELETE FROM node_versions WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
807
|
+
.bind(input.nodeId, input.nodeId),
|
|
808
|
+
// ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
|
|
809
|
+
// one of them would be answered with a resource id that no longer resolves.
|
|
810
|
+
deps.db
|
|
811
|
+
.prepare(`${tree} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
812
|
+
.bind(input.nodeId, input.nodeId),
|
|
813
|
+
// LAST — and its count is what the caller reads as the verdict.
|
|
814
|
+
deps.db
|
|
815
|
+
.prepare(`${tree} DELETE FROM nodes WHERE id IN (SELECT id FROM tree) AND ${alive}`)
|
|
816
|
+
.bind(input.nodeId, input.nodeId),
|
|
817
|
+
]);
|
|
818
|
+
const removed = deleted.at(-1)?.meta?.changes ?? 0;
|
|
819
|
+
if (removed === 0)
|
|
820
|
+
return "missing";
|
|
821
|
+
return { contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))] };
|
|
822
|
+
},
|
|
623
823
|
async appendVersion(input) {
|
|
624
824
|
const version = input.version;
|
|
625
825
|
await deps.db.batch([
|
|
@@ -813,6 +1013,42 @@ export function createNodeRepository(deps) {
|
|
|
813
1013
|
.all();
|
|
814
1014
|
return (result.results ?? []).map(mapGrant);
|
|
815
1015
|
},
|
|
1016
|
+
async listEffectiveAccess(resourceId) {
|
|
1017
|
+
// Owners and grants come from ONE statement, hence one D1 snapshot. Two parallel SELECTs are
|
|
1018
|
+
// not atomic: a grant mutation between their snapshots can produce a principal list that
|
|
1019
|
+
// never existed as a whole.
|
|
1020
|
+
const row = await deps.db
|
|
1021
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
1022
|
+
SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
|
|
1023
|
+
UNION
|
|
1024
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
1025
|
+
FROM nodes parent
|
|
1026
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
1027
|
+
)
|
|
1028
|
+
SELECT
|
|
1029
|
+
(SELECT json_group_array(owner_id)
|
|
1030
|
+
FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
|
|
1031
|
+
(SELECT json_group_array(json_object(
|
|
1032
|
+
'id', id, 'node_id', node_id, 'principal_type', principal_type,
|
|
1033
|
+
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
1034
|
+
'created_by', created_by, 'created_at', created_at
|
|
1035
|
+
))
|
|
1036
|
+
FROM (
|
|
1037
|
+
SELECT grant_row.id, grant_row.node_id, grant_row.principal_type,
|
|
1038
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
1039
|
+
grant_row.created_by, grant_row.created_at
|
|
1040
|
+
FROM node_grants grant_row
|
|
1041
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
1042
|
+
WHERE grant_row.expires_at IS NULL OR grant_row.expires_at > ?
|
|
1043
|
+
ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
|
|
1044
|
+
)) AS grants_json`)
|
|
1045
|
+
.bind(resourceId, deps.now().toISOString())
|
|
1046
|
+
.first();
|
|
1047
|
+
return {
|
|
1048
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
1049
|
+
items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
816
1052
|
// ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
|
|
817
1053
|
// database, the same reason every other walk of this tree gives.
|
|
818
1054
|
async organizationExecuteReaches(nodeId, exceptGrantId) {
|
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, PurgeNodePreviewInput, 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,10 +360,31 @@ 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(await context.req.json().catch(() => null));
|
|
370
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
371
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
372
|
+
}
|
|
373
|
+
return context.json(await deps.nodes.purge(asActor(auth), input));
|
|
374
|
+
});
|
|
375
|
+
app.get("/nodes/:nodeId/purge-preview", async (context) => {
|
|
376
|
+
const auth = requireCapability(context, "nodes", "write");
|
|
377
|
+
const input = PurgeNodePreviewInput.parse({ nodeId: context.req.param("nodeId") });
|
|
378
|
+
return context.json(await deps.nodes.purgePreview(asActor(auth), input));
|
|
379
|
+
});
|
|
363
380
|
app.get("/nodes/:nodeId/grants", async (context) => {
|
|
364
381
|
const auth = requireCapability(context, "nodes", "share");
|
|
365
382
|
return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
|
|
366
383
|
});
|
|
384
|
+
app.get("/nodes/:nodeId/effective-access", async (context) => {
|
|
385
|
+
const auth = requireCapability(context, "nodes", "share");
|
|
386
|
+
return context.json(await deps.nodes.listEffectiveAccess(asActor(auth), context.req.param("nodeId")));
|
|
387
|
+
});
|
|
367
388
|
app.post("/nodes/:nodeId/grants", async (context) => {
|
|
368
389
|
const auth = requireCapability(context, "nodes", "share");
|
|
369
390
|
const input = ShareInput.parse(await context.req.json().catch(() => null));
|
|
@@ -475,6 +496,12 @@ export function createHttp(deps) {
|
|
|
475
496
|
}
|
|
476
497
|
return context.json(await deps.flows.archive(asFlowActor(auth), input));
|
|
477
498
|
});
|
|
499
|
+
// ⚠️ DELETE, and it means it (#457) — the flow's versions and the record of every run it had.
|
|
500
|
+
app.delete("/flows/:flowId", async (context) => {
|
|
501
|
+
const auth = requireCapability(context, "flows", "write");
|
|
502
|
+
const input = PurgeFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
503
|
+
return context.json(await deps.flows.purge(asFlowActor(auth), input));
|
|
504
|
+
});
|
|
478
505
|
app.post("/flows/:flowId/versions", async (context) => {
|
|
479
506
|
const auth = requireCapability(context, "flows", "write");
|
|
480
507
|
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
|
@@ -381,6 +381,20 @@ export function createNodes(deps) {
|
|
|
381
381
|
async get(actor, nodeId) {
|
|
382
382
|
return await getDocument(await requireVisible(actor, nodeId));
|
|
383
383
|
},
|
|
384
|
+
async purgePreview(actor, input) {
|
|
385
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
386
|
+
if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
|
|
387
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
388
|
+
}
|
|
389
|
+
if (!node.archivedAt) {
|
|
390
|
+
throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
|
|
391
|
+
}
|
|
392
|
+
const tree = await deps.repository.inspectPurgeTree(node.id);
|
|
393
|
+
return {
|
|
394
|
+
inboundLinks: await deps.repository.countInboundLinks(node.id),
|
|
395
|
+
totalItems: tree.nodeIds.length + tree.flowIds.length,
|
|
396
|
+
};
|
|
397
|
+
},
|
|
384
398
|
/**
|
|
385
399
|
* The content of one pinned version (#147). Search citations pin the version they quoted, and
|
|
386
400
|
* without this read a citation could name text no surface can show any more.
|
|
@@ -849,6 +863,85 @@ export function createNodes(deps) {
|
|
|
849
863
|
await deps.indexing.enqueue(updated.currentVersionId);
|
|
850
864
|
return updated;
|
|
851
865
|
},
|
|
866
|
+
/**
|
|
867
|
+
* ⚠️ The one call in Intel after which something is really gone (#457). Read the decisions on
|
|
868
|
+
* that issue before changing anything here: each refusal below is one of them, and each was
|
|
869
|
+
* chosen to the safer side.
|
|
870
|
+
*/
|
|
871
|
+
async purge(actor, input) {
|
|
872
|
+
const idempotencyKey = `${input.nodeId}:${input.idempotencyKey}`;
|
|
873
|
+
const replay = await deps.repository.findPurgeReplay(actor.id, idempotencyKey);
|
|
874
|
+
if (replay) {
|
|
875
|
+
for (const key of replay.contentKeys)
|
|
876
|
+
await deps.content.delete(key);
|
|
877
|
+
await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
|
|
878
|
+
return { purged: true, title: replay.title };
|
|
879
|
+
}
|
|
880
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
881
|
+
if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
|
|
882
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
883
|
+
}
|
|
884
|
+
// ⚠️ THE structural guard: only what is already archived may go. A stronger PERMISSION would
|
|
885
|
+
// not have made this safer, only rarer — whoever may archive can already make the content
|
|
886
|
+
// invisible. What makes it safe is that the destructive path has two steps and the first one
|
|
887
|
+
// is reversible.
|
|
888
|
+
if (!node.archivedAt) {
|
|
889
|
+
throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
|
|
890
|
+
}
|
|
891
|
+
// #492 replaces the refusal with owned cleanup. Flows inside the tree disappear with it;
|
|
892
|
+
// published flows outside it are depublished atomically before their dependency vanishes.
|
|
893
|
+
const tree = await deps.repository.inspectPurgeTree(node.id);
|
|
894
|
+
// Not a refusal — a fact for the record. A prose reference is a mention, not an operation;
|
|
895
|
+
// refusing over one would make deleting a lottery, decided by whoever once linked to it.
|
|
896
|
+
//
|
|
897
|
+
// The same count is exposed by `purgePreview`, so the surface can name the consequence before
|
|
898
|
+
// confirmation. It is counted again here because the audit records the state at deletion time.
|
|
899
|
+
const inboundLinks = await deps.repository.countInboundLinks(node.id);
|
|
900
|
+
// ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
|
|
901
|
+
// The comment here used to say Vectorize needed no call because archiving had already emptied
|
|
902
|
+
// it. Archiving only ENQUEUES the purge, and that pass starts from `archivedNodeId(versionId)`
|
|
903
|
+
// — a join over `nodes`. Once this batch has run, that answers `null` and removes nothing,
|
|
904
|
+
// while `node_vectors`, the only record of what the vectors are CALLED, is gone with it. The
|
|
905
|
+
// orphans would then be unnameable forever, and each one keeps a place in the 100-candidate
|
|
906
|
+
// list Vectorize caps per installation.
|
|
907
|
+
//
|
|
908
|
+
// ⚠️ Before the batch rather than after, which inverts the order the content keys follow — and
|
|
909
|
+
// the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
|
|
910
|
+
// vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
|
|
911
|
+
// removal after the batch leaves orphans nothing can name.
|
|
912
|
+
const vectorKeys = tree.vectorKeys;
|
|
913
|
+
// The whole-node chunk carries the empty key and is never in the table — the pass writes it
|
|
914
|
+
// and records the rest, so a purge that only sent the recorded ones would leave it behind.
|
|
915
|
+
if (deps.semantic) {
|
|
916
|
+
for (const vector of vectorKeys) {
|
|
917
|
+
await deps.semantic.remove(vector.nodeId, ["", ...vector.keys]);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const purged = await deps.repository.purgeNode({
|
|
921
|
+
nodeId: node.id,
|
|
922
|
+
actorId: actor.id,
|
|
923
|
+
auditId: deps.id(),
|
|
924
|
+
occurredAt: deps.now().toISOString(),
|
|
925
|
+
// ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
|
|
926
|
+
// and this is what travels beside it. After the delete nothing can look either up.
|
|
927
|
+
metadata: { inboundLinks, counts: tree.counts },
|
|
928
|
+
idempotencyKey,
|
|
929
|
+
});
|
|
930
|
+
// The row was restored or already gone between the check and the statement. Nothing was
|
|
931
|
+
// deleted, and saying so is better than reporting a success that did not happen.
|
|
932
|
+
if (purged === "missing") {
|
|
933
|
+
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
934
|
+
}
|
|
935
|
+
// ⚠️ AFTER the database agreed, never before: it is the truth about what exists, and an object
|
|
936
|
+
// deleted ahead of a batch that then fails would leave a node whose content is gone. This is
|
|
937
|
+
// the same rule the vectors follow in the other direction — do the irrecoverable thing last —
|
|
938
|
+
// and the directions differ because only one of the two can be rebuilt: an index can, an
|
|
939
|
+
// object cannot.
|
|
940
|
+
for (const key of purged.contentKeys)
|
|
941
|
+
await deps.content.delete(key);
|
|
942
|
+
await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
|
|
943
|
+
return { purged: true, title: node.title };
|
|
944
|
+
},
|
|
852
945
|
async listGrants(actor, resourceId) {
|
|
853
946
|
const node = await requireVisible(actor, resourceId);
|
|
854
947
|
if (!(await deps.repository.can(actor, resourceId, "share"))) {
|
|
@@ -860,6 +953,14 @@ export function createNodes(deps) {
|
|
|
860
953
|
items: await deps.repository.listGrants(resourceId),
|
|
861
954
|
};
|
|
862
955
|
},
|
|
956
|
+
async listEffectiveAccess(actor, resourceId) {
|
|
957
|
+
const node = await requireVisible(actor, resourceId);
|
|
958
|
+
if (!(await deps.repository.can(actor, resourceId, "share"))) {
|
|
959
|
+
throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
|
|
960
|
+
}
|
|
961
|
+
const effective = await deps.repository.listEffectiveAccess(resourceId);
|
|
962
|
+
return { resourceId: node.id, ...effective };
|
|
963
|
+
},
|
|
863
964
|
async listLinks(actor, nodeId) {
|
|
864
965
|
await requireVisible(actor, nodeId);
|
|
865
966
|
return { items: await deps.repository.listLinksVisible(actor, nodeId) };
|
|
@@ -84,7 +84,39 @@ 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
|
+
inspectPurgeTree(nodeId: string): Promise<{
|
|
91
|
+
nodeIds: string[];
|
|
92
|
+
flowIds: string[];
|
|
93
|
+
contentKeys: string[];
|
|
94
|
+
vectorKeys: Array<{
|
|
95
|
+
nodeId: string;
|
|
96
|
+
keys: string[];
|
|
97
|
+
}>;
|
|
98
|
+
counts: Record<string, number>;
|
|
99
|
+
}>;
|
|
100
|
+
findPurgeReplay(actorId: string, idempotencyKey: string): Promise<{
|
|
101
|
+
title: string;
|
|
102
|
+
contentKeys: string[];
|
|
103
|
+
} | null>;
|
|
104
|
+
completePurgeReplay(actorId: string, idempotencyKey: string, completedAt: string): Promise<void>;
|
|
105
|
+
purgeNode(input: {
|
|
106
|
+
nodeId: string;
|
|
107
|
+
actorId: string;
|
|
108
|
+
auditId: string;
|
|
109
|
+
occurredAt: string;
|
|
110
|
+
metadata: Record<string, unknown>;
|
|
111
|
+
idempotencyKey: string;
|
|
112
|
+
}): Promise<"missing" | {
|
|
113
|
+
contentKeys: string[];
|
|
114
|
+
}>;
|
|
87
115
|
listGrants(resourceId: string): Promise<ResourceGrant[]>;
|
|
116
|
+
listEffectiveAccess(resourceId: string): Promise<{
|
|
117
|
+
ownerIds: string[];
|
|
118
|
+
items: ResourceGrant[];
|
|
119
|
+
}>;
|
|
88
120
|
organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
|
|
89
121
|
listLinksVisible(actor: Actor, nodeId: string): Promise<NodeLink[]>;
|
|
90
122
|
resolveVisibleTitles(actor: Actor, nodeIds: string[]): Promise<Array<{
|
|
@@ -189,6 +221,19 @@ export interface NodesDeps {
|
|
|
189
221
|
hidden: number;
|
|
190
222
|
}>;
|
|
191
223
|
flowNodeReferences(actor: Actor, folderId: string): Promise<string[]>;
|
|
224
|
+
/**
|
|
225
|
+
* Published flows that use THIS node as a step (#457).
|
|
226
|
+
*
|
|
227
|
+
* ⚠️ Not the same question as `externalFlowCallers`, and the difference cost a refusal that never
|
|
228
|
+
* fired: that one asks who calls a FLOW filed inside a folder (`kind: "subflow"`,
|
|
229
|
+
* `configuration.flowId`). A flow reads a NODE through a tree-link step (`configuration
|
|
230
|
+
* .resourceId`) — a different graph shape entirely, and the one a deletion breaks at run time, in
|
|
231
|
+
* front of somebody who did not order it.
|
|
232
|
+
*/
|
|
233
|
+
flowsUsingNode(actor: Actor, nodeId: string): Promise<{
|
|
234
|
+
visible: string[];
|
|
235
|
+
hidden: number;
|
|
236
|
+
}>;
|
|
192
237
|
/**
|
|
193
238
|
* The handles of the MCP servers this actor reaches through the portal right now (D30).
|
|
194
239
|
*
|
|
@@ -260,7 +305,21 @@ export interface NodeService {
|
|
|
260
305
|
* is off, which stops working loudly and is repaired by repeating the call.
|
|
261
306
|
*/
|
|
262
307
|
archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
|
|
308
|
+
purge(actor: Actor, input: {
|
|
309
|
+
nodeId: string;
|
|
310
|
+
idempotencyKey: string;
|
|
311
|
+
}): Promise<{
|
|
312
|
+
purged: true;
|
|
313
|
+
title: string;
|
|
314
|
+
}>;
|
|
315
|
+
purgePreview(actor: Actor, input: {
|
|
316
|
+
nodeId: string;
|
|
317
|
+
}): Promise<{
|
|
318
|
+
inboundLinks: number;
|
|
319
|
+
totalItems: number;
|
|
320
|
+
}>;
|
|
263
321
|
listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
|
|
322
|
+
listEffectiveAccess(actor: Actor, resourceId: string): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
|
|
264
323
|
listLinks(actor: Actor, nodeId: string): Promise<{
|
|
265
324
|
items: NodeLink[];
|
|
266
325
|
}>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- #492: D1 commits before irreversible R2 deletion. This receipt keeps every object key reachable
|
|
2
|
+
-- when an R2 call fails, and also makes a repeated purge key return the original result.
|
|
3
|
+
CREATE TABLE node_purge_receipts (
|
|
4
|
+
actor_id TEXT NOT NULL,
|
|
5
|
+
idempotency_key TEXT NOT NULL,
|
|
6
|
+
node_id TEXT NOT NULL,
|
|
7
|
+
title TEXT NOT NULL,
|
|
8
|
+
content_keys_json TEXT NOT NULL,
|
|
9
|
+
completed_at TEXT,
|
|
10
|
+
PRIMARY KEY (actor_id, idempotency_key)
|
|
11
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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.17.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|