@danypops/papyrus 0.36.0 → 0.37.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.0",
3
+ "version": "0.37.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"],
package/src/cli.ts CHANGED
@@ -169,6 +169,7 @@ const USAGE = `Usage:
169
169
  papyrus tasks reject <id> [--session-id <id>] [--json]
170
170
  papyrus tasks retry <id> [--session-id <id>] [--json]
171
171
  papyrus tasks cancel <id> [--session-id <id>] [--json]
172
+ papyrus tasks cancel-subtree <id> [--session-id <id>] [--json]
172
173
  papyrus tasks depend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
173
174
  papyrus tasks undepend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
174
175
  papyrus tasks contain <parent-id> <child-id> [--reason <reason>] [--session-id <id>] [--json]
@@ -1740,6 +1741,13 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1740
1741
  human = `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`;
1741
1742
  break;
1742
1743
  }
1744
+ case "cancel-subtree": {
1745
+ if (!id || dependencyId) throw new Error("tasks cancel-subtree requires exactly one task id");
1746
+ const outcome = await client.call<Record<string, unknown>, { canceled: string[]; skipped: string[] }>("tasks.cancel_subtree", { id, actor: "user", source: "cli", ...sessionScope });
1747
+ result = outcome;
1748
+ human = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
1749
+ break;
1750
+ }
1743
1751
  case "depend": {
1744
1752
  if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
1745
1753
  const artifact = await client.call<Record<string, unknown>, CliArtifact>("tasks.depend", {
@@ -1759,7 +1767,7 @@ export async function runTaskCli(args: string[], client: TaskCliClient, projectR
1759
1767
  break;
1760
1768
  }
1761
1769
  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");
1770
+ 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
1771
  }
1764
1772
  return json ? JSON.stringify(result) : human;
1765
1773
  }
package/src/constants.ts CHANGED
@@ -121,6 +121,9 @@ 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
+
124
127
  /**
125
128
  * At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
126
129
  * a Jenkins job, not just a text prompt. A pipeline step can itself trigger another workflow
@@ -28,6 +28,7 @@ import type { ArtifactStore } from "../ports/artifact-store.ts";
28
28
  import type { TaskEventStore } from "../ports/task-event-store.ts";
29
29
  import type { TaskScopeStore } from "../ports/task-scope-store.ts";
30
30
  import { invokePlaybook } from "../playbook-execution.ts";
31
+ import type { SessionIdentity } from "../session-identity-service.ts";
31
32
  import type { Tasks } from "../task-service.ts";
32
33
 
33
34
  const MODULE_ID = "playbooks";
@@ -86,9 +87,11 @@ export interface PlaybooksModuleDeps {
86
87
  artifactScopes: ArtifactScopeStore;
87
88
  /** Used for exactly one thing: focusing the entry task after a successful invoke -- the one safety-checked Tasks operation this module needs, not bulk graph construction (that goes straight through artifacts/events/scopes in playbook-execution.ts, mirroring workflow-execution.ts). */
88
89
  tasks: Tasks;
90
+ /** Guards the same session_secret check tasks.focus's own operation enforces (guardFocusMutation in modules/tasks.ts) -- invoke's internal tasks.focus() call goes straight through the Tasks class, bypassing that operation wrapper entirely, so the check must be applied here instead of silently skipped. */
91
+ sessionIdentity: SessionIdentity;
89
92
  }
90
93
 
91
- export function playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks }: PlaybooksModuleDeps): OperationDefinition[] {
94
+ export function playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }: PlaybooksModuleDeps): OperationDefinition[] {
92
95
  const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
93
96
  name, moduleId: MODULE_ID, execute,
94
97
  });
@@ -109,7 +112,9 @@ export function playbooksOperations({ artifacts, events, scopes, artifactScopes,
109
112
  arguments: input["arguments"] as Record<string, unknown> | undefined,
110
113
  }, { events, scopes, projectRoot: optionalString(input, "project_root"), context: eventContextFor(input, "playbook-run") });
111
114
  if ("missingArguments" in result) return result;
112
- tasks.focus(result.entryTaskId, eventContextFor(input, "playbook-run"));
115
+ const focusContext = eventContextFor(input, "playbook-run");
116
+ sessionIdentity.assertAuthorized(focusContext.sessionId, optionalString(input, "session_secret"));
117
+ tasks.focus(result.entryTaskId, focusContext);
113
118
  return result;
114
119
  }),
115
120
  define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
@@ -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
@@ -359,6 +359,7 @@ function handlers(
359
359
  "tasks.reject": forwardToModule("tasks.reject"),
360
360
  "tasks.retry": forwardToModule("tasks.retry"),
361
361
  "tasks.cancel": forwardToModule("tasks.cancel"),
362
+ "tasks.cancel_subtree": forwardToModule("tasks.cancel_subtree"),
362
363
  "tasks.depend": forwardToModule("tasks.depend"),
363
364
  "tasks.undepend": forwardToModule("tasks.undepend"),
364
365
  "tasks.contain": forwardToModule("tasks.contain"),
@@ -481,7 +482,7 @@ export function createPapyrusService(path: string): PapyrusService {
481
482
  moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
482
483
  moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
483
484
  moduleRegistry.registerAll(skillsOperations({ artifacts, events, scopes, artifactScopes, authority }));
484
- moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks }));
485
+ moduleRegistry.registerAll(playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }));
485
486
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
486
487
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db), moduleRegistry, authority);
487
488
  const state = (): SchemaState => {
@@ -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);