@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 +1 -1
- package/src/artifact-subtree.ts +54 -0
- package/src/cli.ts +18 -2
- package/src/constants.ts +5 -0
- package/src/modules/tasks.ts +2 -1
- package/src/playbook-definition.ts +11 -3
- package/src/service.ts +4 -1
- package/src/task-service.ts +36 -0
package/package.json
CHANGED
|
@@ -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
|
package/src/modules/tasks.ts
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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") &&
|
|
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"),
|
package/src/task-service.ts
CHANGED
|
@@ -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);
|