@danypops/papyrus 0.44.1 → 0.44.3

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.44.1",
3
+ "version": "0.44.3",
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"],
@@ -35,7 +35,7 @@
35
35
  "files": ["src", "README.md"],
36
36
  "dependencies": {
37
37
  "@danypops/vehicle-core": "^0.10.0",
38
- "@danypops/vehicle-client": "^0.5.0",
38
+ "@danypops/vehicle-client": "^0.6.0",
39
39
  "@danypops/vehicle-server": "^0.13.0"
40
40
  }
41
41
  }
package/src/client.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { spawn as spawnProcess } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { connectWithVersionCheck, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
4
- import { readPackageVersion } from "@danypops/vehicle-client/version";
3
+ import { connectWithVersionCheck, type ExpectedVersion, spawnDetachedDaemon } from "@danypops/vehicle-client/daemon-client";
4
+ import { createLiveVersionExpectation } from "@danypops/vehicle-client/version";
5
5
  import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
6
6
  import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon-state.ts";
7
7
  import type { OperationName, SchemaState } from "./service.ts";
8
8
 
9
- /** Compared against the running daemon's /health-reported version by connectWithVersionCheck below -- a long-lived daemon holds whatever code was loaded at its own start. */
10
- const PAPYRUS_VERSION = readPackageVersion(new URL("../package.json", import.meta.url), "Papyrus");
9
+ /**
10
+ * Compared against the running daemon's /health-reported version by connectWithVersionCheck
11
+ * below. Re-read fresh on every call, not cached -- a module-level `const` here was the exact
12
+ * bug that caused repeated, never-self-healing daemon kill/respawn churn on any process that
13
+ * outlived an `npm update` (a respawned daemon runs the same source and reports the same real
14
+ * version, so a stale cached expectation never converges).
15
+ */
16
+ const papyrusExpectedVersion = createLiveVersionExpectation(new URL("../package.json", import.meta.url), "Papyrus");
11
17
 
12
18
  export type FetchAdapter = (request: Request) => Promise<Response>;
13
19
 
@@ -89,7 +95,7 @@ export interface ConnectPapyrusClientOptions {
89
95
  */
90
96
  env?: Record<string, string | undefined>;
91
97
  /** Overrides the version connectWithVersionCheck compares against. Defaults to PAPYRUS_VERSION; test-only -- lets a test force a mismatch against a real daemon without a second build. */
92
- expectedVersion?: string;
98
+ expectedVersion?: ExpectedVersion;
93
99
  }
94
100
 
95
101
  /**
@@ -126,7 +132,7 @@ export async function connectPapyrusClient(
126
132
  fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
127
133
  },
128
134
  {
129
- expectedVersion: options.expectedVersion ?? PAPYRUS_VERSION,
135
+ expectedVersion: options.expectedVersion ?? papyrusExpectedVersion,
130
136
  readVersion: async (client) => (await client.health()).version,
131
137
  killStaleProcess: killStalePapyrusDaemon,
132
138
  },
@@ -45,6 +45,14 @@ export interface PlaybookExternalLink {
45
45
  ownerIsFrom: boolean;
46
46
  }
47
47
 
48
+ /** A Playbook's own composition tree (contains/depends_on nesting, step-level argument merging) is invalid: a cycle, excessive nesting depth, too many materialized tasks/linked artifacts, or conflicting argument types across nodes -- an ordinary, expected authoring mistake, not an unexpected crash. */
49
+ export class PlaybookCompositionError extends Error {
50
+ constructor(message: string) {
51
+ super(message);
52
+ this.name = "PlaybookCompositionError";
53
+ }
54
+ }
55
+
48
56
  export interface CompiledPlaybook {
49
57
  definition: BlueprintDefinition;
50
58
  /** The very first real leaf task in the whole tree's reading order -- what a caller should focus once materialized. */
@@ -118,7 +126,7 @@ interface CompileNodeResult {
118
126
  function mergeArgument(inputs: Record<string, BlueprintInputDefinition>, argument: PlaybookArgument): void {
119
127
  const existing = inputs[argument.name];
120
128
  if (existing && existing.type !== argument.type) {
121
- throw new Error(
129
+ throw new PlaybookCompositionError(
122
130
  `playbook composition declares conflicting types for argument "${argument.name}" (${existing.type} vs ${argument.type})`,
123
131
  );
124
132
  }
@@ -139,9 +147,9 @@ function compileNode(
139
147
  parentRef: string | undefined,
140
148
  incomingPrecedingRefs: string[],
141
149
  ): CompileNodeResult {
142
- if (ancestorIds.has(playbookId)) throw new Error(`playbook composition cycle includes "${playbookId}"`);
150
+ if (ancestorIds.has(playbookId)) throw new PlaybookCompositionError(`playbook composition cycle includes "${playbookId}"`);
143
151
  if (depth > PLAYBOOK_INVOCATION_MAX_CALL_DEPTH)
144
- throw new Error(`playbook composition exceeds ${PLAYBOOK_INVOCATION_MAX_CALL_DEPTH} levels`);
152
+ throw new PlaybookCompositionError(`playbook composition exceeds ${PLAYBOOK_INVOCATION_MAX_CALL_DEPTH} levels`);
145
153
  const nextAncestors = new Set([...ancestorIds, playbookId]);
146
154
 
147
155
  const playbook = requirePlaybook(artifacts, playbookId);
@@ -156,7 +164,7 @@ function compileNode(
156
164
  };
157
165
  ctx.tasks.push(rootBlueprint);
158
166
  if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS)
159
- throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
167
+ throw new PlaybookCompositionError(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
160
168
 
161
169
  const edges = artifacts.relationships({ artifactIds: [playbookId] }).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
162
170
  const composablePlaybookIds = nonTrashedPlaybookIds(
@@ -201,7 +209,7 @@ function compileNode(
201
209
  const stepRef = `${rootRef}-s${index}`;
202
210
  ctx.tasks.push({ ref: stepRef, title, body, parent: rootRef, dependsOn: cursorPrecedingRefs });
203
211
  if (ctx.tasks.length > PLAYBOOK_INVOCATION_MAX_CREATED_TASKS)
204
- throw new Error(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
212
+ throw new PlaybookCompositionError(`playbook invocation exceeds ${PLAYBOOK_INVOCATION_MAX_CREATED_TASKS} tasks`);
205
213
  if (headRef === undefined) headRef = stepRef;
206
214
  cursorPrecedingRefs = [stepRef];
207
215
  tailRef = stepRef;
@@ -1,6 +1,22 @@
1
1
  import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
2
2
  import type { TaskGraph } from "./task-service.ts";
3
3
 
4
+ /** A task execution graph is too large or too densely connected to process safely -- a distinct class (not a plain Error) so the Vehicle adapter layer can classify it into a capacity-category failure instead of an opaque handler-failed. */
5
+ export class TaskExecutionBoundExceededError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "TaskExecutionBoundExceededError";
9
+ }
10
+ }
11
+
12
+ /** The requested dependency edge is invalid: a self-dependency, a cycle, or an endpoint missing from the graph it was checked against. */
13
+ export class TaskDependencyCycleError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = "TaskDependencyCycleError";
17
+ }
18
+ }
19
+
4
20
  export type TaskExecutionState = "todo" | "in-progress" | "review" | "rejected" | "done" | "canceled" | "ready" | "blocked" | "invalid";
5
21
 
6
22
  export interface TaskExecutionNode {
@@ -31,16 +47,16 @@ function executionState(status: string, invalid: boolean, prerequisitesDone: boo
31
47
 
32
48
  function assertBounds(graph: TaskGraph): void {
33
49
  if (graph.nodes.length > TASK_EXECUTION_MAX_NODES) {
34
- throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
50
+ throw new TaskExecutionBoundExceededError(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
35
51
  }
36
52
  for (const node of graph.nodes) {
37
53
  if (node.dependencyIds.length > TASK_EXECUTION_MAX_DEGREE) {
38
- throw new Error(`task "${node.task.id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
54
+ throw new TaskExecutionBoundExceededError(`task "${node.task.id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
39
55
  }
40
56
  }
41
57
  const edgeCount = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
42
58
  if (edgeCount > TASK_EXECUTION_MAX_EDGES) {
43
- throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} dependency edges`);
59
+ throw new TaskExecutionBoundExceededError(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} dependency edges`);
44
60
  }
45
61
  }
46
62
 
@@ -61,7 +77,7 @@ export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
61
77
  for (const prerequisiteId of prerequisites) {
62
78
  const dependentIds = successors.get(prerequisiteId)!;
63
79
  if (dependentIds.length >= TASK_EXECUTION_MAX_DEGREE) {
64
- throw new Error(`task "${prerequisiteId}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
80
+ throw new TaskExecutionBoundExceededError(`task "${prerequisiteId}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
65
81
  }
66
82
  dependentIds.push(node.task.id);
67
83
  }
@@ -113,15 +129,16 @@ export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
113
129
  /** Reject a dependency edge when the prerequisite already reaches the dependent. */
114
130
  export function assertDependencyEdgeAllowed(graph: TaskGraph, id: string, dependencyId: string): void {
115
131
  assertBounds(graph);
116
- if (id === dependencyId) throw new Error(`task "${id}" cannot depend on itself`);
132
+ if (id === dependencyId) throw new TaskDependencyCycleError(`task "${id}" cannot depend on itself`);
117
133
  const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
118
- if (!byId.has(id) || !byId.has(dependencyId)) throw new Error("dependency endpoints must be present in the task graph");
134
+ if (!byId.has(id) || !byId.has(dependencyId))
135
+ throw new TaskDependencyCycleError("dependency endpoints must be present in the task graph");
119
136
 
120
137
  const pending = [dependencyId];
121
138
  const visited = new Set<string>();
122
139
  while (pending.length > 0) {
123
140
  const current = pending.pop()!;
124
- if (current === id) throw new Error(`dependency cycle: "${id}" cannot depend on "${dependencyId}"`);
141
+ if (current === id) throw new TaskDependencyCycleError(`dependency cycle: "${id}" cannot depend on "${dependencyId}"`);
125
142
  if (visited.has(current)) continue;
126
143
  visited.add(current);
127
144
  for (const prerequisiteId of byId.get(current)?.dependencyIds ?? []) pending.push(prerequisiteId);
@@ -37,7 +37,7 @@ import { InMemoryTaskEventStore, type TaskEventStore } from "./ports/task-event-
37
37
  import { InMemoryTaskFocusStore, type TaskFocusStatus, type TaskFocusStore } from "./ports/task-focus-store.ts";
38
38
  import { InMemoryTaskLeaseStore, type TaskLeaseStore } from "./ports/task-lease-store.ts";
39
39
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "./ports/task-scope-store.ts";
40
- import { assertDependencyEdgeAllowed } from "./task-execution.ts";
40
+ import { assertDependencyEdgeAllowed, TaskExecutionBoundExceededError } from "./task-execution.ts";
41
41
 
42
42
  export interface UpdateTaskInput {
43
43
  title?: string;
@@ -625,16 +625,16 @@ export class Tasks {
625
625
  return this.events.atomic(() => {
626
626
  this.require(id);
627
627
  this.require(dependencyId);
628
- const graph = this.graph();
628
+ const graph = this.dependencyCheckGraph(id, dependencyId);
629
629
  assertDependencyEdgeAllowed(graph, id, dependencyId);
630
630
  const node = graph.nodes.find((entry) => entry.task.id === id)!;
631
631
  if (node.dependencyIds.includes(dependencyId)) return this.show(id);
632
632
  if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
633
- throw new Error(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
633
+ throw new TaskExecutionBoundExceededError(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
634
634
  }
635
635
  const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
636
636
  if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
637
- throw new Error(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
637
+ throw new TaskExecutionBoundExceededError(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
638
638
  }
639
639
  this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
640
640
  this.appendEvent({ taskId: id, type: "dependency_added", reason: context.reason }, context);
@@ -642,6 +642,23 @@ export class Tasks {
642
642
  });
643
643
  }
644
644
 
645
+ /**
646
+ * Scopes the cycle-check graph to the two endpoints' shared project when they have one, instead
647
+ * of building the whole daemon's graph -- a small project's own dependency check must never fail
648
+ * just because unrelated tasks in other projects pushed the daemon-wide total over
649
+ * TASK_EXECUTION_MAX_NODES. Falls back to the unscoped graph when the endpoints don't share a
650
+ * known project (a genuine cross-project dependency), preserving the prior, correct behavior for
651
+ * that rarer case.
652
+ */
653
+ private dependencyCheckGraph(id: string, dependencyId: string): TaskGraph {
654
+ const sourceProject = this.scopes.get(id)?.projectRoot;
655
+ const targetProject = this.scopes.get(dependencyId)?.projectRoot;
656
+ if (sourceProject !== undefined && sourceProject === targetProject) {
657
+ return this.graph({ projectRoot: sourceProject, scope: "project" });
658
+ }
659
+ return this.graph();
660
+ }
661
+
645
662
  /** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
646
663
  undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
647
664
  return this.events.atomic(() => {
@@ -875,7 +892,7 @@ export class Tasks {
875
892
  }
876
893
 
877
894
  /**
878
- * Discuss's forcing behavior (see domain/discussion.ts): an active Discussion doc that
895
+ * Discuss's forcing behavior (see discussions/discussion.ts): an active Discussion doc that
879
896
  * `blocks` this task refuses its completion until settled or deferred. A discussion whose
880
897
  * extra.discussion shape is missing or corrupt is treated as non-blocking rather than
881
898
  * crashing completion -- the same fail-open posture Task Focus's opt-in armor uses for an
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import { defineVehicleSchema, type VehicleContentBlock, VehicleError, type VehicleSchemaCodec } from "@danypops/vehicle-core";
7
7
  import type { Artifact } from "../domain/artifact.ts";
8
+ import { PlaybookCompositionError } from "../playbook-definition.ts";
8
9
  import type { ArtifactStore } from "../ports/artifact-store.ts";
9
- import type { TaskExecutionPlan } from "../task-execution.ts";
10
+ import { InvalidSessionSecretError } from "../session-identity-service.ts";
11
+ import { TaskDependencyCycleError, TaskExecutionBoundExceededError, type TaskExecutionPlan } from "../task-execution.ts";
10
12
 
11
13
  /**
12
14
  * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
@@ -60,6 +62,69 @@ export function validationError(message: string): VehicleError {
60
62
  return new VehicleError("validation-failed", message, { category: "validation" });
61
63
  }
62
64
 
65
+ /**
66
+ * tasks.focus/pause/unpause/clear_focus and playbooks.invoke all re-run
67
+ * sessionIdentity.assertAuthorized(session_id, session_secret) directly, bypassing the guarded
68
+ * tasks.focus operation (see modules/tasks.ts's guardFocusMutation and modules/playbooks.ts's own
69
+ * doc comment) -- a real, registered session's own auth failure is an ordinary, expected outcome,
70
+ * not an unexpected crash, so it must surface as its own classified VehicleError. Real incident:
71
+ * this used to arrive only inside .cause of an opaque "... handler failed", invisible to a caller
72
+ * that doesn't already know to dig for it. Anything else propagates unchanged, so vehicle-registry's
73
+ * own secure-by-default handler-failed opacity still applies to a genuine unexpected crash.
74
+ */
75
+ export function classifySessionAuthorization<T>(run: () => T): T {
76
+ try {
77
+ return run();
78
+ } catch (error) {
79
+ if (error instanceof InvalidSessionSecretError) {
80
+ throw new VehicleError("invalid-session-secret", error.message, { category: "authorization" });
81
+ }
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * A task execution graph (or a workflow/playbook run materializing one) that exceeds its own
88
+ * node/edge/degree bound is an ordinary, expected capacity failure, not an unexpected crash --
89
+ * must surface as its own classified VehicleError instead of vehicle-registry's generic
90
+ * handler-failed. Shared by tasks-vehicle.ts (create/depend/contain/graph/plan/complete) and
91
+ * playbooks-vehicle.ts (invoke, which materializes Tasks through the same shared engine).
92
+ */
93
+ export function classifyTaskExecutionBounds<T>(run: () => T): T {
94
+ try {
95
+ return run();
96
+ } catch (error) {
97
+ if (error instanceof TaskExecutionBoundExceededError) {
98
+ throw new VehicleError("task-execution-bound-exceeded", error.message, { category: "capacity" });
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ /** A self-dependency or dependency-cycle rejection (tasks.depend/undepend/create) is an ordinary, expected validation failure, not an unexpected crash. */
105
+ export function classifyTaskDependencyCycles<T>(run: () => T): T {
106
+ try {
107
+ return run();
108
+ } catch (error) {
109
+ if (error instanceof TaskDependencyCycleError) {
110
+ throw new VehicleError("task-dependency-cycle", error.message, { category: "validation" });
111
+ }
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ /** A Playbook's own composition tree (contains/depends_on nesting) is invalid -- a cycle, excessive depth/size, or conflicting argument types -- an ordinary, expected authoring mistake caught at playbooks.invoke/preview compile time, not an unexpected crash. */
117
+ export function classifyPlaybookComposition<T>(run: () => T): T {
118
+ try {
119
+ return run();
120
+ } catch (error) {
121
+ if (error instanceof PlaybookCompositionError) {
122
+ throw new VehicleError("playbook-composition-invalid", error.message, { category: "validation" });
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
63
128
  /** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
64
129
  export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
65
130
  const value = input[key];
@@ -31,6 +31,9 @@ import type { SessionIdentity } from "../session-identity-service.ts";
31
31
  import type { Tasks } from "../task-service.ts";
32
32
  import {
33
33
  buildWorkflowRunContent,
34
+ classifyPlaybookComposition,
35
+ classifySessionAuthorization,
36
+ classifyTaskExecutionBounds,
34
37
  looseObjectSchema,
35
38
  normalizeJsonEncodedField,
36
39
  numberProp,
@@ -64,7 +67,19 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
64
67
  const moduleOperations = new Map(
65
68
  playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity }).map((op) => [op.name, op]),
66
69
  );
67
- const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
70
+ /**
71
+ * Every playbooks.* action funnels through here. invoke's own module handler re-runs
72
+ * sessionIdentity.assertAuthorized directly (see this file's own doc comment) and its
73
+ * blueprint-materialization engine (workflow-execution.ts) can hit the same execution-graph
74
+ * bounds tasks-vehicle.ts's own operations do -- classifying both reviewed domain error classes
75
+ * at this one choke point covers every action that can throw them. Anything else propagates
76
+ * unchanged -- vehicle-registry's own secure-by-default handler-failed opacity still applies to a
77
+ * genuine unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
78
+ */
79
+ const call = (name: string, input: Record<string, unknown>): unknown =>
80
+ classifySessionAuthorization(() =>
81
+ classifyTaskExecutionBounds(() => classifyPlaybookComposition(() => moduleOperations.get(name)!.execute(input))),
82
+ );
68
83
 
69
84
  const define = (
70
85
  action: string,
@@ -28,6 +28,9 @@ import type { SessionIdentity } from "../session-identity-service.ts";
28
28
  import type { TaskExecutionPlan } from "../task-execution.ts";
29
29
  import type { TaskCompletion, Tasks } from "../task-service.ts";
30
30
  import {
31
+ classifySessionAuthorization,
32
+ classifyTaskDependencyCycles,
33
+ classifyTaskExecutionBounds,
31
34
  labelsById,
32
35
  looseObjectSchema,
33
36
  numberProp,
@@ -144,7 +147,17 @@ function planContentText(plan: TaskExecutionPlan): string {
144
147
  export function registerTasksVehicleOperations(registry: VehicleRegistry, deps: TasksVehicleDeps): void {
145
148
  const { tasks, artifacts, sessionIdentity } = deps;
146
149
  const moduleOperations = new Map(tasksOperations(tasks, artifacts, sessionIdentity).map((op) => [op.name, op]));
147
- const call = (name: string, input: Record<string, unknown>): unknown => moduleOperations.get(name)!.execute(input);
150
+ /**
151
+ * Every tasks.* action funnels through here, so classifying a handful of reviewed domain error
152
+ * classes (execution-graph bounds, dependency cycles, Task Focus authorization) at this one
153
+ * choke point covers every action that can throw them. Anything else propagates unchanged --
154
+ * vehicle-registry's own secure-by-default handler-failed opacity still applies to a genuine
155
+ * unexpected crash (see artifact-vehicle-shared.ts's classify* helpers).
156
+ */
157
+ const call = (name: string, input: Record<string, unknown>): unknown =>
158
+ classifySessionAuthorization(() =>
159
+ classifyTaskExecutionBounds(() => classifyTaskDependencyCycles(() => moduleOperations.get(name)!.execute(input))),
160
+ );
148
161
 
149
162
  const define = (
150
163
  action: string,
@@ -21,7 +21,7 @@ import type { ArtifactStore } from "./ports/artifact-store.ts";
21
21
  import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
22
22
  import type { TaskEventStore } from "./ports/task-event-store.ts";
23
23
  import type { TaskScopeStore } from "./ports/task-scope-store.ts";
24
- import { projectTaskExecution, type TaskExecutionPlan } from "./task-execution.ts";
24
+ import { projectTaskExecution, TaskExecutionBoundExceededError, type TaskExecutionPlan } from "./task-execution.ts";
25
25
  import type { TaskGraph, TaskNode, TaskStatus } from "./task-service.ts";
26
26
 
27
27
  const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
@@ -304,7 +304,7 @@ export function materializeWorkflowDefinition(
304
304
  rendered.blueprints.tasks.filter((task) => (task.dependsOn?.length ?? 0) === 0).length +
305
305
  rendered.blueprints.skills.filter((call) => (call.dependsOn?.length ?? 0) === 0).length;
306
306
  if (relationshipCount > TASK_EXECUTION_MAX_EDGES) {
307
- throw new Error(`workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
307
+ throw new TaskExecutionBoundExceededError(`workflow run exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
308
308
  }
309
309
 
310
310
  const docs = rendered.blueprints.docs.map((blueprint) =>