@danypops/papyrus 0.54.3 → 0.54.5

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.54.3",
3
+ "version": "0.54.5",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -15,6 +15,17 @@
15
15
  "serve": "bun src/cli.ts serve",
16
16
  "service:install": "bun src/cli.ts service install"
17
17
  },
18
+ "packed": {
19
+ "daemonService": {
20
+ "name": "papyrus",
21
+ "displayName": "Papyrus graph artifact service",
22
+ "binPath": "src/cli.ts",
23
+ "args": ["serve"],
24
+ "handleFilename": "vehicle-handle.json",
25
+ "restartOnFailure": true,
26
+ "restartSec": 2
27
+ }
28
+ },
18
29
  "devDependencies": {
19
30
  "bun-types": "latest",
20
31
  "typescript": "^5.7.3"
package/src/client.ts CHANGED
@@ -126,18 +126,31 @@ export interface ConnectPapyrusClientOptions {
126
126
  env?: Record<string, string | undefined>;
127
127
  /** 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. */
128
128
  expectedVersion?: ExpectedVersion;
129
+ /**
130
+ * Overrides the default fail-closed behavior (see connectPapyrusClient's own doc comment for
131
+ * why that default changed). Test-only -- covers the auto-spawn mechanism itself
132
+ * (connect-client-auto-spawn.test.ts) and lets other real-daemon integration tests keep using
133
+ * it as convenient bootstrap. A real, non-Armada-supervised standalone deployment could also
134
+ * set this explicitly, but that is not this option's primary purpose.
135
+ */
136
+ autoStart?: boolean;
129
137
  }
130
138
 
131
139
  /**
132
- * Transparently starts the daemon first if it is not already running -- matches
133
- * every other daemon-backed ecosystem package that opted into auto-start (see
134
- * @danypops/vehicle-client's connectWithPolicy doc comment: web-spider opts in,
135
- * lector/pi-packed fail closed by design). Papyrus previously failed closed with
136
- * "install/start papyrus.service", requiring a human to separately discover and
137
- * run `papyrus service install` before the very first tool call could succeed.
138
- * A handle file that exists but points at a dead/unreachable daemon is a distinct
139
- * failure (stale, not "never started") and is NOT auto-recovered here -- it still
140
- * throws its own actionable "restart manually" error, unchanged from before.
140
+ * Fails closed if nothing is reachable at all -- matches lector/pi-packed (see
141
+ * @danypops/vehicle-client's connectWithPolicy doc comment). Papyrus used to auto-start here
142
+ * (autoStart: true) because it predated being a properly Armada-supervised Vehicle; that flag
143
+ * is now a genuine liability, not a convenience: a real, live race was traced to it directly --
144
+ * Armada's own systemd unit and THIS client's auto-spawn each independently tried to be "the one
145
+ * that starts Papyrus" whenever no daemon was momentarily reachable (e.g. mid-restart), and
146
+ * whichever one's child won the OS-level single-instance-lock race became an orphan invisible to
147
+ * systemd's own tracking, permanently confusing `armada status`. Now that Armada's systemd unit
148
+ * (Restart=on-failure) is the one and only thing responsible for keeping Papyrus running, a
149
+ * client that finds nothing reachable should say so plainly, not compete to fix it.
150
+ *
151
+ * Stale-VERSION self-healing (an already-running daemon reporting an older version than this
152
+ * client expects) is unrelated to autoStart and still works: connectWithVersionCheck's own kill-
153
+ * and-replace path only requires `spawn` to be defined, independent of the autoStart flag above.
141
154
  */
142
155
  export async function connectPapyrusClient(
143
156
  dir: string = daemonStateDir(),
@@ -147,7 +160,7 @@ export async function connectPapyrusClient(
147
160
  {
148
161
  readHandle: () => readDaemonHandle(dir) ?? null,
149
162
  buildClient: probedPapyrusClient,
150
- autoStart: true,
163
+ autoStart: options.autoStart ?? false,
151
164
  spawn: () => {
152
165
  spawnDetachedDaemon({
153
166
  binPath: papyrusCliPath(),
@@ -156,7 +169,8 @@ export async function connectPapyrusClient(
156
169
  spawn: spawnPapyrusDaemonProcess,
157
170
  });
158
171
  },
159
- fallbackMessage: "Papyrus daemon failed to start automatically; run `papyrus service install` or `papyrus serve` manually.",
172
+ fallbackMessage:
173
+ "Papyrus daemon is not running; Armada should be supervising it -- run `armada status`/`armada reconcile`, or `papyrus serve` directly if this is a standalone (non-Armada) setup.",
160
174
  },
161
175
  {
162
176
  expectedVersion: options.expectedVersion ?? papyrusExpectedVersion,
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Gate-execution engine, split out of ops.ts (the artifact-CRUD file) as part of a SOLID-audit-
3
+ * driven decomposition (see Doc "Modularity playbook: building-block-shaped TypeScript modules
4
+ * for papyrus/pi-papyrus" and the "gate-execution engine" child of "Epic: Modularize papyrus/
5
+ * pi-papyrus god-files into building-block modules"). This logic was already unified in a prior
6
+ * refactor (sync/async outcome evaluation shared via evaluateProcessGateResult/spawnErrorGateResult)
7
+ * but still lived inside the artifact-CRUD file until now.
8
+ *
9
+ * Only `runGates`/`runGatesAsync` are real public API -- verified via find_references before this
10
+ * move, not assumed from a grep hit count: the only two real importers were
11
+ * stores/sqlite-gate-runner.ts and test/ops.test.ts (every other `runGates`-named hit in the
12
+ * codebase is Tasks.runGates, a same-named but distinct method that calls into this module only
13
+ * indirectly, through the GateRunner port). Both were updated to import from this file directly;
14
+ * no barrel re-export needed.
15
+ *
16
+ * Depends on ops.ts's own `getArtifact` (still the right owner of that read -- it's real
17
+ * artifact-CRUD, not gate-execution's own concern) -- a one-directional dependency, since ops.ts
18
+ * no longer needs to import anything back from here.
19
+ */
20
+ import { createRequire } from "node:module";
21
+ import {
22
+ GATE_COMMAND_TIMEOUT_MS,
23
+ GATE_FILE_MAX_BYTES,
24
+ GATE_MAX_BUFFER_BYTES,
25
+ GATE_OUTPUT_LIMIT,
26
+ GATE_TEST_TIMEOUT_MS,
27
+ } from "../constants.ts";
28
+ import type { Db } from "../db.ts";
29
+ import { getArtifact } from "../ops.ts";
30
+ import type { Gate, GateResult, GateRunOptions } from "./gate.ts";
31
+
32
+ const require_ = createRequire(import.meta.url);
33
+
34
+ function readBoundedGateFile(path: string): string {
35
+ const { readFileSync, statSync } = require_("node:fs");
36
+ if (statSync(path).size > GATE_FILE_MAX_BYTES) throw new Error(`file exceeds ${GATE_FILE_MAX_BYTES} bytes`);
37
+ return readFileSync(path, "utf-8") as string;
38
+ }
39
+
40
+ /**
41
+ * Shared by the sync and async process-gate runners so "test" is never a second, independently
42
+ * maintained copy of "command"'s own command-template selection.
43
+ *
44
+ * "test" runs `gate.target` verbatim, exactly like "command" -- the only real difference is a
45
+ * more generous default timeout (GATE_TEST_TIMEOUT_MS vs GATE_COMMAND_TIMEOUT_MS), since a test
46
+ * suite routinely runs longer than an arbitrary command. It previously wrapped target in
47
+ * `npx vitest run ${target} --reporter=dot`, silently wrong for every real consumer in this
48
+ * ecosystem (all Bun-native, none use vitest): a target that was itself a full command (e.g.
49
+ * `bun test path/to.test.ts`, exactly what every existing gate/checklist example here has always
50
+ * shown) got parsed by vitest as three separate positional args, triggering vitest's own broad
51
+ * discovery across the whole repo instead of running the intended command at all -- a real
52
+ * incident (task ab1463e2) that produced an unrelated multi-suite vitest failure cascade instead
53
+ * of the actual target ever running.
54
+ */
55
+ function processGateCommand(gate: Gate): { command: string; timeout: number } {
56
+ if (gate.type === "test") return { command: gate.target, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
57
+ return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
58
+ }
59
+
60
+ /**
61
+ * Keeps the LAST GATE_OUTPUT_LIMIT characters, not the first -- a real command's own meaningful
62
+ * pass/fail summary is its last lines, not its first (setup/banner noise). See GATE_OUTPUT_LIMIT's
63
+ * own doc comment (constants.ts) for the real incident this fixes.
64
+ */
65
+ function gateOutputTail(text: string): string {
66
+ return text.length > GATE_OUTPUT_LIMIT ? text.slice(-GATE_OUTPUT_LIMIT) : text;
67
+ }
68
+
69
+ /**
70
+ * The one place "did this process gate pass, and what should its display output say" is decided,
71
+ * shared by the sync (runProcessGateSync) and async (executeGateCommand's caller) process-gate
72
+ * runners -- previously two hand-copied inline checks that already had to be fixed twice, by
73
+ * hand, more than once (gate.expect seeing stderr too; GATE_OUTPUT_LIMIT's truncation direction).
74
+ * `matchable` must be the FULL captured output (bounded only by GATE_MAX_BUFFER_BYTES / Node's own
75
+ * spawnSync maxBuffer, never GATE_OUTPUT_LIMIT), so gate.expect always sees the whole run, never
76
+ * the truncated display copy `output` becomes.
77
+ */
78
+ function evaluateProcessGateResult(gate: Gate, code: number | null, matchable: string): { passed: boolean; output: string } {
79
+ const exitedZero = code === 0;
80
+ return {
81
+ passed: exitedZero && (gate.expect ? matchable.includes(gate.expect) : true),
82
+ output: gateOutputTail(matchable) || (exitedZero ? "ok" : `command exited with code ${code}`),
83
+ };
84
+ }
85
+
86
+ /** The other shared branch: a literal spawn-level failure (command not found, spawnSync's own maxBuffer exceeded, etc.) -- distinct from a process that ran and exited non-zero, which evaluateProcessGateResult above handles. Identical treatment on both the sync and async paths: the raw error message, tail-truncated like any other gate output. */
87
+ function spawnErrorGateResult(gate: Gate, error: Error): GateResult {
88
+ return { gate, passed: false, output: gateOutputTail(error.message) };
89
+ }
90
+
91
+ function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
92
+ const { spawnSync } = require_("node:child_process");
93
+ const { command, timeout } = processGateCommand(gate);
94
+ const result = spawnSync(command, { shell: true, encoding: "utf-8", timeout, ...(cwd ? { cwd } : {}) });
95
+ if (result.error) return spawnErrorGateResult(gate, result.error);
96
+ const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
97
+ return { gate, ...evaluateProcessGateResult(gate, result.status, combined) };
98
+ }
99
+
100
+ /**
101
+ * Runs one gate command with two invariants a prior implementation lacked (a real incident; see
102
+ * GateRunOptions.cwd's doc comment):
103
+ * 1. `cwd` is always explicit, never inherited from the daemon's own process cwd.
104
+ * 2. The whole process group is killed on timeout, not just the immediate shell. `exec()`'s own
105
+ * `timeout` option only signals the process it directly spawned (the shell running
106
+ * `command`); a shell's own child (e.g. `bun` under `sh -c "bun test"`) is not in general
107
+ * killed by that signal and can be reparented and keep running -- and consuming memory --
108
+ * indefinitely after Papyrus considers the gate "timed out". Spawning detached (its own
109
+ * process group) and killing the negated pid on our own timer reaches the whole tree.
110
+ */
111
+ function executeGateCommand(gate: Gate, command: string, timeout: number, cwd?: string): Promise<GateResult> {
112
+ // `spawn(..., { shell: true, detached: true })` instead of the `exec()` convenience wrapper:
113
+ // `detached` (needed to make the shell the leader of its own process group, so the negated pid
114
+ // below reaches every descendant, not just the shell) is not part of Node's `exec()`/
115
+ // `ExecOptions` type at all -- `spawn`'s options support it directly and correctly.
116
+ const { spawn } = require_("node:child_process") as typeof import("node:child_process");
117
+ return new Promise((resolve) => {
118
+ let settled = false;
119
+ let buffered = "";
120
+ let truncated = false;
121
+ const child = spawn(command, { shell: true, detached: true, ...(cwd ? { cwd } : {}) });
122
+
123
+ const append = (chunk: Buffer): void => {
124
+ if (truncated) return;
125
+ buffered += chunk.toString("utf8");
126
+ if (buffered.length > GATE_MAX_BUFFER_BYTES) {
127
+ buffered = buffered.slice(0, GATE_MAX_BUFFER_BYTES);
128
+ truncated = true;
129
+ }
130
+ };
131
+ child.stdout?.on("data", append);
132
+ child.stderr?.on("data", append);
133
+
134
+ const finish = (result: GateResult): void => {
135
+ if (settled) return;
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ resolve(result);
139
+ };
140
+
141
+ child.on("error", (error) => finish(spawnErrorGateResult(gate, error)));
142
+ child.on("close", (code) => finish({ gate, ...evaluateProcessGateResult(gate, code, buffered.trim()) }));
143
+
144
+ const timer = setTimeout(() => {
145
+ if (settled) return;
146
+ if (child.pid !== undefined) {
147
+ try {
148
+ process.kill(-child.pid, "SIGKILL");
149
+ } catch {
150
+ child.kill("SIGKILL");
151
+ }
152
+ }
153
+ finish({ gate, passed: false, output: `gate command timed out after ${timeout}ms` });
154
+ }, timeout);
155
+ });
156
+ }
157
+
158
+ function runNonProcessGate(gate: Gate): GateResult {
159
+ if (gate.type === "file-exists") {
160
+ const { existsSync } = require_("node:fs");
161
+ const exists = existsSync(gate.target);
162
+ return { gate, passed: exists, output: exists ? "exists" : "not found" };
163
+ }
164
+ if (gate.type === "contains") {
165
+ try {
166
+ const content = readBoundedGateFile(gate.target);
167
+ const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
168
+ return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
169
+ } catch {
170
+ return { gate, passed: false, output: "file not readable" };
171
+ }
172
+ }
173
+ return { gate, passed: false, output: `unknown gate type: ${String(gate.type)}` };
174
+ }
175
+
176
+ export function runGates(db: Db, artifactId: string, options: GateRunOptions = {}): GateResult[] {
177
+ const art = getArtifact(db, artifactId);
178
+ if (!art) throw new Error("artifact not found");
179
+ const gates = (art.extra.gates as Gate[]) ?? [];
180
+ const cwd = options.cwd;
181
+ return gates.map((gate) => (gate.type === "command" || gate.type === "test" ? runProcessGateSync(gate, cwd) : runNonProcessGate(gate)));
182
+ }
183
+
184
+ /** Gate runner for daemon request paths; subprocess gates never block the event loop. */
185
+ export async function runGatesAsync(db: Db, artifactId: string, options: GateRunOptions = {}): Promise<GateResult[]> {
186
+ const art = getArtifact(db, artifactId);
187
+ if (!art) throw new Error("artifact not found");
188
+ const gates = (art.extra.gates as Gate[]) ?? [];
189
+ const results: GateResult[] = [];
190
+ for (const gate of gates) {
191
+ const remainingMs = options.deadlineMs === undefined ? undefined : options.deadlineMs - Date.now();
192
+ if (remainingMs !== undefined && remainingMs <= 0) {
193
+ results.push({ gate, passed: false, output: "gate runtime deadline exceeded" });
194
+ continue;
195
+ }
196
+ if (gate.type === "command" || gate.type === "test") {
197
+ const { command, timeout: configuredTimeout } = processGateCommand(gate);
198
+ const timeout = remainingMs === undefined ? configuredTimeout : Math.max(1, Math.min(configuredTimeout, remainingMs));
199
+ results.push(await executeGateCommand(gate, command, timeout, options.cwd));
200
+ } else {
201
+ results.push(runNonProcessGate(gate));
202
+ }
203
+ }
204
+ return results;
205
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Cross-domain artifact name/id resolution and workflow-run narrative building, split out of
3
+ * handlers/shared.ts as part of a SOLID-audit-driven decomposition (see Doc "Modularity playbook:
4
+ * building-block-shaped TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts
5
+ * split" child of "Epic: Modularize papyrus/pi-papyrus god-files into building-block modules").
6
+ */
7
+ import { type VehicleContentBlock, VehicleError } from "@danypops/vehicle-core";
8
+ import type { Artifact } from "../artifact/artifact.ts";
9
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
10
+ import type { TaskExecutionPlan } from "../task/task-execution.ts";
11
+ import { validationError } from "./operation-schema.ts";
12
+
13
+ /** 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. */
14
+ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
15
+ const value = input[key];
16
+ if (typeof value !== "string") return;
17
+ try {
18
+ input[key] = JSON.parse(value);
19
+ } catch {
20
+ throw validationError(`${key} must be valid JSON`);
21
+ }
22
+ }
23
+
24
+ /** Exact match semantics as the Pi-extension helper this replaces (domain-tools.ts's matchArtifactByName) -- case-insensitive exact title match, refuses to guess between ambiguous matches. */
25
+ export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
26
+ const needle = name.trim().toLowerCase();
27
+ const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
28
+ if (matches.length === 0) {
29
+ throw new VehicleError("artifact-not-found", `no artifact named "${name}" found in this scope`, { category: "not_found" });
30
+ }
31
+ if (matches.length > 1) {
32
+ throw new VehicleError(
33
+ "artifact-name-ambiguous",
34
+ `${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.alias})`).join(", ")} -- use id or alias to disambiguate`,
35
+ { category: "conflict" },
36
+ );
37
+ }
38
+ return matches[0]!.id;
39
+ }
40
+
41
+ /**
42
+ * Resolves a name to an id. Checks `artifacts.getByAlias` first -- a real, indexed,
43
+ * globally-unique match, unlike title -- before falling back to today's scoped
44
+ * title-based matching, retrying against `fetchWidened` (an unscoped/cross-project
45
+ * search) only when `fetchCandidates` finds nothing. Owns the match-or-widen control
46
+ * flow only -- the caller supplies its own scoped/widened list calls, since scoping
47
+ * differs per domain. Omit `fetchWidened` when there is no wider scope to retry.
48
+ */
49
+ export function resolveArtifactIdWidened(
50
+ artifacts: ArtifactStore,
51
+ name: string,
52
+ fetchCandidates: () => readonly Artifact[],
53
+ fetchWidened?: () => readonly Artifact[],
54
+ ): string {
55
+ const byAlias = artifacts.getByAlias(name.trim());
56
+ if (byAlias) return byAlias.id;
57
+ try {
58
+ return matchArtifactByName(fetchCandidates(), name);
59
+ } catch (error) {
60
+ if (!(error instanceof VehicleError) || error.code !== "artifact-not-found" || !fetchWidened) throw error;
61
+ return matchArtifactByName(fetchWidened(), name);
62
+ }
63
+ }
64
+
65
+ /** Synchronous equivalent of pi-papyrus's own artifactLabelsById -- server-side, a direct ArtifactStore.get() replaces the extra RPC round-trip that helper needed client-side. Always suffixes the alias -- a short, meaningful, globally-unique reference, unlike the raw UUID it replaces. */
66
+ export function labelsById(artifacts: ArtifactStore, ids: readonly string[]): Map<string, string> {
67
+ const uniqueIds = [...new Set(ids)];
68
+ const resolved = uniqueIds.map((id) => artifacts.get(id)).filter((artifact): artifact is Artifact => artifact !== null);
69
+ return new Map(resolved.map((artifact) => [artifact.id, `${artifact.title} (${artifact.alias})`]));
70
+ }
71
+
72
+ export interface WorkflowRunNarrativeInput {
73
+ runId: string;
74
+ created: { docs: readonly string[]; rules: readonly string[]; tasks: readonly string[] };
75
+ rootTaskIds: readonly string[];
76
+ execution: TaskExecutionPlan;
77
+ }
78
+
79
+ /**
80
+ * Builds the model-facing `content` text for a workflow run result (ready roots, context docs,
81
+ * scoped rules, an execution tree) directly, so the model reads a summary instead of the raw
82
+ * execution DAG -- the same shape pi-papyrus's own hand-rolled playbooks tool built
83
+ * client-side, now built once here where the run result is actually produced.
84
+ */
85
+ export function buildWorkflowRunContent(
86
+ artifacts: ArtifactStore,
87
+ headline: string,
88
+ input: WorkflowRunNarrativeInput,
89
+ extraLines: readonly string[] = [],
90
+ ): VehicleContentBlock {
91
+ const nodeById = new Map(input.execution.nodes.map((node) => [node.id, node]));
92
+ const rootLabels = input.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
93
+ const createdLabels = labelsById(artifacts, [...input.created.docs, ...input.created.rules]);
94
+ const titleCounts = new Map<string, number>();
95
+ for (const node of input.execution.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
96
+ const executionLines = input.execution.nodes
97
+ .map((node) =>
98
+ (titleCounts.get(node.title) ?? 0) > 1 ? ` [${node.state}] ${node.title} (${node.id})` : ` [${node.state}] ${node.title}`,
99
+ )
100
+ .join("\n");
101
+ const text = [
102
+ headline,
103
+ ...extraLines,
104
+ `Ready roots: ${rootLabels.join(", ") || "none"}.`,
105
+ `Context docs: ${input.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
106
+ `Scoped rules: ${input.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
107
+ ...(executionLines ? ["Execution:", executionLines] : []),
108
+ ].join("\n");
109
+ return { type: "text", text };
110
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Generic, domain-agnostic operation-input schema DSL, split out of handlers/shared.ts as part of
3
+ * a SOLID-audit-driven decomposition (see Doc "Modularity playbook: building-block-shaped
4
+ * TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts split" child of "Epic:
5
+ * Modularize papyrus/pi-papyrus god-files into building-block modules"). Nothing here references
6
+ * a specific Papyrus domain (tasks/docs/rules/...) -- a real candidate to eventually become its
7
+ * own building block other Vehicle-backed daemons could reuse directly.
8
+ */
9
+ import {
10
+ defineVehicleSchema,
11
+ type JsonSchema,
12
+ VehicleError,
13
+ type VehicleSchemaCodec,
14
+ type VehicleSchemaIssue,
15
+ } from "@danypops/vehicle-core";
16
+
17
+ export interface OperationSchemaNode {
18
+ readonly type?: string | readonly string[];
19
+ readonly enum?: readonly unknown[];
20
+ readonly properties?: Readonly<Record<string, OperationSchemaNode>>;
21
+ readonly required?: readonly string[];
22
+ readonly additionalProperties?: boolean | OperationSchemaNode;
23
+ /** A key not in `properties` is validated against the first pattern here whose RegExp matches it, instead of falling through to `additionalProperties` -- e.g. a free-form string-keyed map (tasks.create's checklist) uses `{"^.*$": entrySchema}` so a client-side JSON-Schema validator that reports `additionalProperties`-as-schema violations only as a generic top-level "must not have additional properties" (TypeBox's own real, confirmed behavior -- see vehicle-shell.ts's formatSchemaChildren for the matching tools_man rendering) instead descends into the real nested violation, matching an array's `items` precision. */
24
+ readonly patternProperties?: Readonly<Record<string, OperationSchemaNode>>;
25
+ readonly items?: OperationSchemaNode;
26
+ readonly minLength?: number;
27
+ readonly maxLength?: number;
28
+ readonly minimum?: number;
29
+ readonly maximum?: number;
30
+ readonly minItems?: number;
31
+ readonly maxItems?: number;
32
+ readonly description?: string;
33
+ readonly [key: string]: unknown;
34
+ }
35
+
36
+ function schemaIssue(path: readonly (string | number)[], message: string): VehicleSchemaIssue[] {
37
+ return [{ path, message }];
38
+ }
39
+
40
+ function matchesSchemaType(value: unknown, type: string): boolean {
41
+ if (type === "object") return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ if (type === "array") return Array.isArray(value);
43
+ if (type === "string") return typeof value === "string";
44
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
45
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value);
46
+ if (type === "boolean") return typeof value === "boolean";
47
+ return true;
48
+ }
49
+
50
+ function validateSchemaValue(value: unknown, schema: OperationSchemaNode, path: readonly (string | number)[]): VehicleSchemaIssue[] {
51
+ const label = path.length === 0 ? "input" : String(path.at(-1));
52
+ const declaredTypes = typeof schema.type === "string" ? [schema.type] : (schema.type ?? []);
53
+ const type = declaredTypes.find((candidate) => matchesSchemaType(value, candidate));
54
+ if (declaredTypes.length > 0 && type === undefined) {
55
+ const accepted = declaredTypes.map((candidate) =>
56
+ candidate === "integer" ? "an integer" : `${candidate === "object" ? "an" : "a"} ${candidate}`,
57
+ );
58
+ return schemaIssue(path, `${label} must be ${accepted.join(" or ")}`);
59
+ }
60
+ if (type === "object") {
61
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return schemaIssue(path, `${label} must be an object`);
62
+ const record = value as Record<string, unknown>;
63
+ for (const key of schema.required ?? []) {
64
+ if (!(key in record)) {
65
+ const acceptedShape = schema.description ? `; ${schema.description}` : "";
66
+ return schemaIssue([...path, key], `${key} is required${acceptedShape}`);
67
+ }
68
+ }
69
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
70
+ if (!(key in record)) continue;
71
+ const issues = validateSchemaValue(record[key], child, [...path, key]);
72
+ if (issues.length > 0) return issues;
73
+ }
74
+ for (const key of Object.keys(record)) {
75
+ if (key in (schema.properties ?? {})) continue;
76
+ const patternMatch = Object.entries(schema.patternProperties ?? {}).find(([pattern]) => new RegExp(pattern).test(key));
77
+ if (patternMatch) {
78
+ const issues = validateSchemaValue(record[key], patternMatch[1], [...path, key]);
79
+ if (issues.length > 0) return issues;
80
+ continue;
81
+ }
82
+ if (schema.additionalProperties === false) return schemaIssue([...path, key], `${key} is not allowed`);
83
+ if (typeof schema.additionalProperties === "object") {
84
+ const issues = validateSchemaValue(record[key], schema.additionalProperties, [...path, key]);
85
+ if (issues.length > 0) return issues;
86
+ }
87
+ }
88
+ } else if (type === "array") {
89
+ const entries = value as unknown[];
90
+ if (schema.minItems !== undefined && entries.length < schema.minItems) {
91
+ return schemaIssue(path, `${label} must contain at least ${schema.minItems} item(s)`);
92
+ }
93
+ if (schema.maxItems !== undefined && entries.length > schema.maxItems) {
94
+ return schemaIssue(path, `${label} cannot contain more than ${schema.maxItems} item(s)`);
95
+ }
96
+ if (schema.items) {
97
+ for (const [index, entry] of entries.entries()) {
98
+ const issues = validateSchemaValue(entry, schema.items, [...path, index]);
99
+ if (issues.length > 0) return issues;
100
+ }
101
+ }
102
+ } else if (type === "string") {
103
+ const text = value as string;
104
+ if (schema.minLength !== undefined && text.length < schema.minLength) {
105
+ return schemaIssue(path, `${label} must contain at least ${schema.minLength} character(s)`);
106
+ }
107
+ if (schema.maxLength !== undefined && text.length > schema.maxLength) {
108
+ return schemaIssue(path, `${label} cannot exceed ${schema.maxLength} character(s)`);
109
+ }
110
+ } else if (type === "number" || type === "integer") {
111
+ const number = value as number;
112
+ if (schema.minimum !== undefined && number < schema.minimum) {
113
+ return schemaIssue(path, `${label} must be at least ${schema.minimum}`);
114
+ }
115
+ if (schema.maximum !== undefined && number > schema.maximum) {
116
+ return schemaIssue(path, `${label} cannot exceed ${schema.maximum}`);
117
+ }
118
+ }
119
+ if (schema.enum && !schema.enum.includes(value)) {
120
+ return schemaIssue(path, `${label} must be one of ${schema.enum.join(", ")}`);
121
+ }
122
+ return [];
123
+ }
124
+
125
+ /** VehicleRegistry executes this codec before resolving or dispatching an operation. Keep the
126
+ * recursive runtime checks aligned with the same JSON Schema clients and tools_man receive. */
127
+ export function looseObjectSchema(
128
+ properties: Readonly<Record<string, OperationSchemaNode>>,
129
+ required: readonly string[] = [],
130
+ ): VehicleSchemaCodec<Record<string, unknown>> {
131
+ const schema = { type: "object", properties, required: [...required], additionalProperties: false } as const;
132
+ return defineVehicleSchema<Record<string, unknown>>({
133
+ jsonSchema: schema as unknown as JsonSchema,
134
+ safeParse(value) {
135
+ const issues = validateSchemaValue(value, schema, []);
136
+ return issues.length > 0 ? { success: false, issues } : { success: true, value: value as Record<string, unknown> };
137
+ },
138
+ });
139
+ }
140
+
141
+ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchema<unknown>({
142
+ jsonSchema: { type: "object" },
143
+ safeParse: (value) => ({ success: true, value }),
144
+ });
145
+
146
+ export const stringProp = { type: "string" } as const;
147
+ export const numberProp = { type: "number" } as const;
148
+ export const booleanProp = { type: "boolean" } as const;
149
+
150
+ /**
151
+ * A plain `throw new Error(...)` inside any resolve()/execute() step here is caught by
152
+ * vehicle-registry.ts's generic dispatch and re-wrapped as VehicleError("handler-failed",
153
+ * `${key} handler failed`, {category: "internal"}) -- built to catch a genuine crash, but
154
+ * it can't distinguish that from an ordinary, expected validation/lookup failure, so it
155
+ * discards the original message and category either way. Every guard clause and name
156
+ * resolution below must throw a VehicleError directly so it passes through that dispatch
157
+ * unchanged (vehicle-registry.ts only rewraps errors that are NOT already a VehicleError).
158
+ */
159
+ export function validationError(message: string): VehicleError {
160
+ return new VehicleError("validation-failed", message, { category: "validation" });
161
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Vehicle-operation-definer DSL (createOperationDefiner) and the paired add/remove mutation shape
3
+ * built on top of it (definePairedMutation), split out of handlers/shared.ts as part of a
4
+ * SOLID-audit-driven decomposition (see Doc "Modularity playbook: building-block-shaped
5
+ * TypeScript modules for papyrus/pi-papyrus" and the "handlers/shared.ts split" child of "Epic:
6
+ * Modularize papyrus/pi-papyrus god-files into building-block modules").
7
+ */
8
+ import { bindVehicleOperation, defineVehicleOperation, type VehicleLimits, type VehicleOperationContext } from "@danypops/vehicle-core";
9
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
10
+ import { looseObjectSchema, passthroughOutput } from "./operation-schema.ts";
11
+
12
+ export type OperationSchemaProperties = Record<
13
+ string,
14
+ { type: string | readonly string[]; enum?: readonly string[]; description?: string; [key: string]: unknown }
15
+ >;
16
+
17
+ export type DefineOperation = (
18
+ action: string,
19
+ description: string,
20
+ effect: "read" | "local-write",
21
+ properties: OperationSchemaProperties,
22
+ required: readonly string[],
23
+ resolve: (input: Record<string, unknown>) => Record<string, unknown>,
24
+ execute?: (input: Record<string, unknown>, context: VehicleOperationContext<Record<string, unknown>>) => unknown,
25
+ /**
26
+ * Overrides this one operation's own Vehicle transport limits, distinct from every other
27
+ * operation this same createOperationDefiner call produces. For an operation that shells out
28
+ * to and waits on a real external command (e.g. tasks.run_gates/tasks.complete) rather than an
29
+ * instant CRUD read/write -- see handlers/tasks.ts's GATE_OPERATION_LIMITS for the motivating
30
+ * case. Omit to keep the definer's own default limits, unchanged for every other action.
31
+ */
32
+ limits?: VehicleLimits,
33
+ ) => void;
34
+
35
+ const STANDARD_OPERATION_LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
36
+
37
+ /**
38
+ * Every *-vehicle.ts handler wires up the identical defineVehicleOperation +
39
+ * bindVehicleOperation + registry.register triple per action, differing only in
40
+ * owner/domain-prefix/permissions and (for tasks/playbooks) a real execute() override
41
+ * in place of the default "call the wrapped module operation" behavior. One factory,
42
+ * called once per domain, replaces that repetition.
43
+ */
44
+ export function createOperationDefiner(
45
+ registry: VehicleRegistry,
46
+ owner: string,
47
+ domain: string,
48
+ permissions: readonly [string, string],
49
+ defaultCall: (name: string, input: Record<string, unknown>) => unknown,
50
+ ): DefineOperation {
51
+ return (action, description, effect, properties, required, resolve, execute, limits) => {
52
+ const operation = defineVehicleOperation({
53
+ name: `${domain}.${action}`,
54
+ version: 1,
55
+ description,
56
+ input: looseObjectSchema(properties, required),
57
+ output: passthroughOutput,
58
+ permissions: [...permissions],
59
+ effect,
60
+ idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
61
+ limits: limits ?? STANDARD_OPERATION_LIMITS,
62
+ });
63
+ registry.register(
64
+ owner,
65
+ bindVehicleOperation(
66
+ operation,
67
+ () => async (context) =>
68
+ (execute ?? ((input: Record<string, unknown>) => defaultCall(`${domain}.${action}`, input)))(resolve(context.input), context),
69
+ ),
70
+ );
71
+ };
72
+ }
73
+
74
+ export interface PairedMutationFieldSpec {
75
+ idProp: string;
76
+ nameProp: string;
77
+ }
78
+
79
+ /**
80
+ * depend/undepend and contain/uncontain (tasks-vehicle.ts, playbooks-vehicle.ts) share
81
+ * one shape: two id-or-name fields resolved the same way for both the add and the
82
+ * remove action, differing only in action name/description. One call replaces two
83
+ * near-identical define() invocations.
84
+ */
85
+ export function definePairedMutation(
86
+ define: DefineOperation,
87
+ first: PairedMutationFieldSpec,
88
+ second: PairedMutationFieldSpec,
89
+ properties: OperationSchemaProperties,
90
+ required: readonly string[],
91
+ resolveId: (input: Record<string, unknown>, idProp: string, nameProp: string) => string,
92
+ add: { action: string; description: string },
93
+ remove: { action: string; description: string },
94
+ ): void {
95
+ const resolve = (input: Record<string, unknown>): Record<string, unknown> => ({
96
+ ...input,
97
+ [first.idProp]: resolveId(input, first.idProp, first.nameProp),
98
+ [second.idProp]: resolveId(input, second.idProp, second.nameProp),
99
+ });
100
+ define(add.action, add.description, "local-write", properties, required, resolve);
101
+ define(remove.action, remove.description, "local-write", properties, required, resolve);
102
+ }