@danypops/papyrus 0.37.0 → 0.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Bulk trash of a `contains` subtree, any artifact kind -- a whole materialized Task run
3
+ * (root container + steps + nested playbook children) or a Playbook's own nested-playbook
4
+ * tree can be moved to trash in one call instead of enumerating every id by hand. Mirrors
5
+ * Tasks.cancelSubtree's traversal shape but performs trash(), not a lifecycle transition,
6
+ * so it applies to any kind that participates in `contains` (task, playbook), not just Task.
7
+ */
8
+ import { ARTIFACT_REMOVE_SUBTREE_MAX_NODES } from "./constants.ts";
9
+ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
10
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
11
+ import type { ArtifactTrashStore } from "./ports/artifact-trash-store.ts";
12
+
13
+ export interface RemoveSubtreeResult {
14
+ removed: string[];
15
+ /** Already trashed -- a real no-op, not an error, matching trash()/restore()'s own idempotence elsewhere. */
16
+ skipped: string[];
17
+ }
18
+
19
+ /**
20
+ * Trashes `id` and every artifact reachable by following `contains` edges outward from it,
21
+ * transitively. An already-trashed node is skipped rather than re-trashed (trash() itself is
22
+ * idempotent, but skipping keeps the result's `removed` list meaningful -- only nodes newly
23
+ * moved to trash by this call). A node that is the live Task Focus in some scope still throws
24
+ * (the same guard trash() always enforces for a single artifact) rather than being silently
25
+ * skipped -- an active Focus is a real conflict to surface, not routine already-done state.
26
+ */
27
+ export function removeArtifactSubtree(
28
+ store: ArtifactStore & ArtifactTrashStore,
29
+ id: string,
30
+ options: { reason?: string; context?: ArtifactEventContext } = {},
31
+ ): RemoveSubtreeResult {
32
+ if (!store.get(id)) throw new Error(`artifact "${id}" not found`);
33
+ const visited = new Set<string>();
34
+ const queue = [id];
35
+ const removed: string[] = [];
36
+ const skipped: string[] = [];
37
+ while (queue.length > 0) {
38
+ const current = queue.shift()!;
39
+ if (visited.has(current)) continue;
40
+ visited.add(current);
41
+ if (visited.size > ARTIFACT_REMOVE_SUBTREE_MAX_NODES) throw new Error(`remove_subtree exceeds ${ARTIFACT_REMOVE_SUBTREE_MAX_NODES} artifacts`);
42
+ const childIds = store.relationships({ artifactIds: [current] })
43
+ .filter((edge) => edge.from === current && edge.relation === "contains")
44
+ .map((edge) => edge.to);
45
+ queue.push(...childIds);
46
+ if (store.trashStatus(current)) {
47
+ skipped.push(current);
48
+ continue;
49
+ }
50
+ store.trash(current, { reason: options.reason, context: options.context });
51
+ removed.push(current);
52
+ }
53
+ return { removed, skipped };
54
+ }
package/src/cli.ts CHANGED
@@ -83,6 +83,7 @@ const USAGE = `Usage:
83
83
  papyrus artifact query [--kind <kind>] [--status <status>] [--text <query>] [--limit <count>] [--json]
84
84
  papyrus artifact show <id> [--depth <n>] [--max-nodes <n>] [--json]
85
85
  papyrus artifact remove <id> [--reason <text>] [--json]
86
+ papyrus artifact remove-subtree <id> [--reason <text>] [--json]
86
87
  papyrus artifact restore <id> [--json]
87
88
  papyrus artifact trash-status <id> [--json]
88
89
  papyrus artifact trash-list [--json]
@@ -1023,6 +1024,13 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
1023
1024
  human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
1024
1025
  break;
1025
1026
  }
1027
+ case "remove-subtree": {
1028
+ if (!id) throw new Error("artifact remove-subtree requires exactly one artifact id");
1029
+ const outcome = await client.call<Record<string, unknown>, { removed: string[]; skipped: string[] }>("artifact.remove_subtree", { id, reason });
1030
+ result = outcome;
1031
+ human = `Trashed ${outcome.removed.length} artifact(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-trashed` : ""}.`;
1032
+ break;
1033
+ }
1026
1034
  case "restore": {
1027
1035
  if (!id) throw new Error("artifact restore requires exactly one artifact id");
1028
1036
  const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
@@ -1045,7 +1053,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
1045
1053
  break;
1046
1054
  }
1047
1055
  default:
1048
- throw new Error("artifact action must be create, query, show, remove, restore, trash-status, or trash-list");
1056
+ throw new Error("artifact action must be create, query, show, remove, remove-subtree, restore, trash-status, or trash-list");
1049
1057
  }
1050
1058
  return json ? JSON.stringify(result) : human;
1051
1059
  }
package/src/constants.ts CHANGED
@@ -123,6 +123,8 @@ export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
123
123
 
124
124
  /** Tasks.cancelSubtree walks `contains` edges transitively (a whole materialized playbook run can be torn down in one call instead of enumerating every task id by hand) -- bounded the same way PLAYBOOK_INVOCATION_MAX_CREATED_TASKS bounds the forward direction. */
125
125
  export const TASK_CANCEL_SUBTREE_MAX_NODES = 500;
126
+ /** artifact.remove_subtree walks `contains` transitively across any artifact kind (a task tree, or a playbook's own nested-playbook children) -- same bound rationale as TASK_CANCEL_SUBTREE_MAX_NODES, kept separate since the two traversals serve different operations. */
127
+ export const ARTIFACT_REMOVE_SUBTREE_MAX_NODES = 500;
126
128
 
127
129
  /**
128
130
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
package/src/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
2
2
  import { VERSION } from "./version.ts";
3
3
  import { migrateDb, openDb, schemaVersion } from "./db.ts";
4
+ import { removeArtifactSubtree } from "./artifact-subtree.ts";
4
5
  import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
5
6
  import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
6
7
  import { SQLiteArtifactScopeStore } from "./adapters/sqlite-artifact-scope-store.ts";
@@ -57,7 +58,7 @@ import { SQLiteDiscussionRoundStore } from "./adapters/sqlite-discussion-round-s
57
58
  */
58
59
  const COMPOSITION_ROOT_OPERATION_NAMES = [
59
60
  "system.migrate", "artifact.create", "artifact.query", "artifact.show",
60
- "artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
61
+ "artifact.remove", "artifact.remove_subtree", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
61
62
  "graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
62
63
  "rules.injectable", "skills.instantiate",
63
64
  ] as const;
@@ -280,6 +281,7 @@ function handlers(
280
281
  maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
281
282
  }),
282
283
  "artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
284
+ "artifact.remove_subtree": (input) => removeArtifactSubtree(artifacts, string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
283
285
  "artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
284
286
  "artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
285
287
  "artifact.trash_list": () => artifacts.listTrash(),