@danypops/papyrus 0.44.0 → 0.44.2
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 +2 -2
- package/src/daemon.ts +10 -0
- package/src/playbook-definition.ts +13 -5
- package/src/task-execution.ts +24 -7
- package/src/task-service.ts +22 -5
- package/src/vehicle/artifact-vehicle-shared.ts +66 -1
- package/src/vehicle/playbooks-vehicle.ts +16 -1
- package/src/vehicle/tasks-vehicle.ts +14 -1
- package/src/workflow-execution.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.2",
|
|
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"],
|
|
@@ -36,6 +36,6 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@danypops/vehicle-core": "^0.10.0",
|
|
38
38
|
"@danypops/vehicle-client": "^0.5.0",
|
|
39
|
-
"@danypops/vehicle-server": "^0.
|
|
39
|
+
"@danypops/vehicle-server": "^0.13.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { acquireDaemonLock, releaseDaemonLock } from "@danypops/vehicle-server/paths";
|
|
1
3
|
import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
2
4
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "./constants.ts";
|
|
3
5
|
import { clearDaemonPort, daemonStateDir, loadOrCreateToken, writeDaemonPort } from "./daemon-state.ts";
|
|
@@ -28,6 +30,12 @@ const TASK_READ_ONLY_OPERATIONS = new Set([
|
|
|
28
30
|
/** Start the supervised, long-running Papyrus service. */
|
|
29
31
|
export function serveMain(): void {
|
|
30
32
|
const stateDir = daemonStateDir();
|
|
33
|
+
const lockPath = join(stateDir, "daemon.lock");
|
|
34
|
+
const lock = acquireDaemonLock(lockPath);
|
|
35
|
+
if (!lock.acquired) {
|
|
36
|
+
logEvent("info", "already_running", { holderPid: lock.holderPid });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
31
39
|
const token = loadOrCreateToken(stateDir);
|
|
32
40
|
const service = createPapyrusService(dbPath());
|
|
33
41
|
const pushChannel = new PushChannel({ token });
|
|
@@ -54,6 +62,7 @@ export function serveMain(): void {
|
|
|
54
62
|
});
|
|
55
63
|
if (!server.port) {
|
|
56
64
|
service.close();
|
|
65
|
+
releaseDaemonLock(lockPath);
|
|
57
66
|
throw new Error("Papyrus daemon failed to bind a listener");
|
|
58
67
|
}
|
|
59
68
|
writeDaemonPort(stateDir, server.port);
|
|
@@ -101,6 +110,7 @@ export function serveMain(): void {
|
|
|
101
110
|
clearInterval(reapFocusTimer);
|
|
102
111
|
clearInterval(purgeTrashTimer);
|
|
103
112
|
clearDaemonPort(stateDir);
|
|
113
|
+
releaseDaemonLock(lockPath);
|
|
104
114
|
service.close();
|
|
105
115
|
// .finally() re-throws rather than handling a rejection -- catching it first turns a bare
|
|
106
116
|
// unhandled-rejection warning into a real, queryable shutdown-failure log line.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/src/task-execution.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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))
|
|
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
|
|
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);
|
package/src/task-service.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|