@danypops/papyrus 0.36.1 → 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.36.1",
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]
@@ -169,6 +170,7 @@ const USAGE = `Usage:
169
170
  papyrus tasks reject <id> [--session-id <id>] [--json]
170
171
  papyrus tasks retry <id> [--session-id <id>] [--json]
171
172
  papyrus tasks cancel <id> [--session-id <id>] [--json]
173
+ papyrus tasks cancel-subtree <id> [--session-id <id>] [--json]
172
174
  papyrus tasks depend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
173
175
  papyrus tasks undepend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
174
176
  papyrus tasks contain <parent-id> <child-id> [--reason <reason>] [--session-id <id>] [--json]
@@ -1022,6 +1024,13 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
1022
1024
  human = `Trashed ${record.artifactId}: eligible for purge at ${record.purgeAfter}`;
1023
1025
  break;
1024
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
+ }
1025
1034
  case "restore": {
1026
1035
  if (!id) throw new Error("artifact restore requires exactly one artifact id");
1027
1036
  const outcome = await client.call<Record<string, unknown>, { restored: boolean }>("artifact.restore", { id });
@@ -1044,7 +1053,7 @@ export async function runArtifactCli(args: string[], client: TaskCliClient, proj
1044
1053
  break;
1045
1054
  }
1046
1055
  default:
1047
- 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");
1048
1057
  }
1049
1058
  return json ? JSON.stringify(result) : human;
1050
1059
  }
@@ -1740,6 +1749,13 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1740
1749
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
1741
1750
  break;
1742
1751
  }
1752
+ case "cancel-subtree": {
1753
+ if (!id || dependencyId) throw new Error("tasks cancel-subtree requires exactly one task id");
1754
+ const outcome = await client.call<Record<string, unknown>, { canceled: string[]; skipped: string[] }>("tasks.cancel_subtree", { id, actor: "user", source: "cli", ...sessionScope });
1755
+ result = outcome;
1756
+ human = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
1757
+ break;
1758
+ }
1743
1759
  case "depend": {
1744
1760
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
1745
1761
  const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.depend", {
@@ -1759,7 +1775,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1759
1775
  break;
1760
1776
  }
1761
1777
  default:
1762
- throw new Error("tasks action must be create, list, show, active, focused, focus, pause, unpause, clear-focus, update, graph, plan, context, history, scope, assign-project, complete, start, submit, reject, retry, cancel, depend, undepend, contain, uncontain, run-gates, set-checklist, or set-gates");
1778
+ throw new Error("tasks action must be create, list, show, active, focused, focus, pause, unpause, clear-focus, update, graph, plan, context, history, scope, assign-project, complete, start, submit, reject, retry, cancel, cancel-subtree, depend, undepend, contain, uncontain, run-gates, set-checklist, or set-gates");
1763
1779
  }
1764
1780
  return json ? JSON.stringify(result) : human;
1765
1781
  }
package/src/constants.ts CHANGED
@@ -121,6 +121,11 @@ export const PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH = 500;
121
121
  */
122
122
  export const PLAYBOOK_INVOCATION_MAX_CREATED_TASKS = 200;
123
123
 
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
+ 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;
128
+
124
129
  /**
125
130
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
126
131
  * a Jenkins job, not just a text prompt. A pipeline step can itself trigger another workflow
@@ -90,7 +90,7 @@ export const TASKS_OPERATION_NAMES = [
90
90
  "tasks.create", "tasks.update", "tasks.list", "tasks.graph", "tasks.plan", "tasks.show", "tasks.history",
91
91
  "tasks.scope", "tasks.set_scope", "tasks.assign_project", "tasks.active", "tasks.focused", "tasks.focus",
92
92
  "tasks.pause", "tasks.unpause", "tasks.clear_focus", "tasks.start", "tasks.submit", "tasks.complete",
93
- "tasks.run_gates", "tasks.set_checklist", "tasks.set_gates", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel",
93
+ "tasks.run_gates", "tasks.set_checklist", "tasks.set_gates", "tasks.context", "tasks.reject", "tasks.retry", "tasks.cancel", "tasks.cancel_subtree",
94
94
  "tasks.depend", "tasks.undepend", "tasks.contain", "tasks.uncontain", "tasks.reap_stale_focus",
95
95
  "tasks.claim", "tasks.heartbeat_lease", "tasks.release_lease", "tasks.lease", "tasks.reap_stale_leases", "tasks.event_feed",
96
96
  ] as const;
@@ -169,6 +169,7 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
169
169
  define("tasks.reject", (input: OperationInput) => tasks.transition(string(input, "id"), "reject", eventContext(input))),
170
170
  define("tasks.retry", (input: OperationInput) => tasks.transition(string(input, "id"), "retry", eventContext(input))),
171
171
  define("tasks.cancel", (input: OperationInput) => tasks.transition(string(input, "id"), "cancel", eventContext(input))),
172
+ define("tasks.cancel_subtree", (input: OperationInput) => tasks.cancelSubtree(string(input, "id"), eventContext(input))),
172
173
  define("tasks.depend", (input: OperationInput) => tasks.depend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
173
174
  define("tasks.undepend", (input: OperationInput) => tasks.undepend(string(input, "id"), string(input, "dependency_id"), eventContext(input))),
174
175
  define("tasks.contain", (input: OperationInput) => tasks.contain(string(input, "parent_id"), string(input, "child_id"), eventContext(input))),
@@ -42,10 +42,17 @@ export interface CompiledPlaybook {
42
42
  externalLinks: PlaybookExternalLink[];
43
43
  }
44
44
 
45
+ /** Trashed but not yet purged: artifacts.get() still returns it (trash is separate, orthogonal metadata -- "still directly showable"), so a stale composition edge left behind by remove/uncontain would otherwise resolve straight through and get compiled in. query() excludes trashed artifacts by default; use that instead of get() for every existence check a compiler makes. */
46
+ function nonTrashedPlaybookIds(artifacts: ArtifactStore, ids: string[]): Set<string> {
47
+ if (ids.length === 0) return new Set();
48
+ return new Set(artifacts.query({ ids, kind: "playbook" }).map((artifact) => artifact.id));
49
+ }
50
+
45
51
  function requirePlaybook(artifacts: ArtifactStore, id: string): Artifact {
46
52
  const playbook = artifacts.get(id);
47
53
  if (!playbook) throw new Error(`playbook artifact "${id}" not found`);
48
54
  if (playbook.kind !== "playbook") throw new Error(`artifact "${id}" is not a playbook`);
55
+ if (!nonTrashedPlaybookIds(artifacts, [id]).has(id)) throw new Error(`playbook artifact "${id}" is trashed`);
49
56
  return playbook;
50
57
  }
51
58
 
@@ -121,12 +128,13 @@ function compileNode(
121
128
  if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS) throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
122
129
 
123
130
  const edges = artifacts.relationships({ artifactIds: [playbookId] }).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
131
+ const composablePlaybookIds = nonTrashedPlaybookIds(artifacts, edges.filter((edge) => edge.from === playbookId).map((edge) => edge.to));
124
132
  const prerequisiteIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "depends_on").map((edge) => edge.to)
125
- .filter((id) => artifacts.get(id)?.kind === "playbook");
133
+ .filter((id) => composablePlaybookIds.has(id));
126
134
  const nestedIds = edges.filter((edge) => edge.from === playbookId && edge.relation === "contains").map((edge) => edge.to)
127
- .filter((id) => artifacts.get(id)?.kind === "playbook");
135
+ .filter((id) => composablePlaybookIds.has(id));
128
136
  for (const edge of edges) {
129
- const isComposingFrom = edge.from === playbookId && (edge.relation === "contains" || edge.relation === "depends_on") && artifacts.get(edge.to)?.kind === "playbook";
137
+ const isComposingFrom = edge.from === playbookId && (edge.relation === "contains" || edge.relation === "depends_on") && composablePlaybookIds.has(edge.to);
130
138
  if (isComposingFrom) continue;
131
139
  if (edge.from === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.to, ownerIsFrom: true });
132
140
  else if (edge.to === playbookId) ctx.externalLinks.push({ rootRef, relation: edge.relation, otherArtifactId: edge.from, ownerIsFrom: false });
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(),
@@ -359,6 +361,7 @@ function handlers(
359
361
  "tasks.reject": forwardToModule("tasks.reject"),
360
362
  "tasks.retry": forwardToModule("tasks.retry"),
361
363
  "tasks.cancel": forwardToModule("tasks.cancel"),
364
+ "tasks.cancel_subtree": forwardToModule("tasks.cancel_subtree"),
362
365
  "tasks.depend": forwardToModule("tasks.depend"),
363
366
  "tasks.undepend": forwardToModule("tasks.undepend"),
364
367
  "tasks.contain": forwardToModule("tasks.contain"),
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  TASK_BODY_MAX_LENGTH,
3
+ TASK_CANCEL_SUBTREE_MAX_NODES,
3
4
  TASK_EXECUTION_MAX_DEGREE,
4
5
  TASK_EXECUTION_MAX_EDGES,
5
6
  TASK_EXECUTION_MAX_NODES,
@@ -459,6 +460,41 @@ export class Tasks {
459
460
  });
460
461
  }
461
462
 
463
+ /**
464
+ * Cancels a task and every task in its containment subtree (`contains` edges, transitively) --
465
+ * a whole materialized playbook/skill run can be torn down in one call instead of enumerating
466
+ * every task id by hand. A task already in a terminal state (done/canceled) is skipped, not
467
+ * treated as an error, matching how a mixed-status subtree is the normal case (some steps
468
+ * genuinely finished before the rest needed to be abandoned). Does not follow `depends_on` --
469
+ * only containment cascades, a prerequisite is a different unit of work.
470
+ */
471
+ cancelSubtree(id: string, context: TaskEventContext = {}): { canceled: string[]; skipped: string[] } {
472
+ this.require(id);
473
+ const visited = new Set<string>();
474
+ const queue = [id];
475
+ const canceled: string[] = [];
476
+ const skipped: string[] = [];
477
+ while (queue.length > 0) {
478
+ const current = queue.shift()!;
479
+ if (visited.has(current)) continue;
480
+ visited.add(current);
481
+ if (visited.size > TASK_CANCEL_SUBTREE_MAX_NODES) throw new Error(`cancelSubtree exceeds ${TASK_CANCEL_SUBTREE_MAX_NODES} tasks`);
482
+ const task = this.artifacts.get(current);
483
+ if (!task || task.kind !== "task") continue;
484
+ const childIds = this.artifacts.relationships({ artifactIds: [current] })
485
+ .filter((edge) => edge.from === current && edge.relation === "contains")
486
+ .map((edge) => edge.to);
487
+ queue.push(...childIds);
488
+ if (task.status === "done" || task.status === "canceled") {
489
+ skipped.push(current);
490
+ continue;
491
+ }
492
+ this.transition(current, "cancel", context);
493
+ canceled.push(current);
494
+ }
495
+ return { canceled, skipped };
496
+ }
497
+
462
498
  complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
463
499
  const task = this.requireReview(id);
464
500
  this.requireNotBlocked(task);