@danypops/papyrus 0.46.1 → 0.47.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/README.md CHANGED
@@ -46,6 +46,8 @@ Task project names are explicit registrations, never ambient-directory guesses.
46
46
 
47
47
  `tasks.create` accepts an optional `idempotency_key`. Replays with the same caller, canonical project root, key, and payload return the original response without another mutation; conflicting payload reuse is rejected. Keys are retained for seven days, isolated across callers and projects, and then expire. Retry only when reusing the exact key and payload; an unkeyed create remains unsafe to replay after an ambiguous transport failure.
48
48
 
49
+ Task lifecycle mutations (`start`, `submit`, `reject`, `retry`, `cancel`, `reopen`, `complete`, `pause`, and `unpause`) are destination-state idempotent: repeating an already-achieved transition is a successful `changed: false` no-op and creates no duplicate history. Supply an `idempotency_key` for every mutation whose response could be lost. After an unknown outcome, call `tasks.show` and `tasks.mutation_status` with the original key, then replay only that exact operation/key if needed—never invent a new key from stale state. Completed receipts are retained for seven days; concurrent duplicate completion calls share one gate run. A genuinely incompatible transition returns typed `invalid-transition` details with current/intended status, allowed actions, and recovery guidance.
50
+
49
51
  Task lease responses are name-first: `tasks.claim`, `tasks.heartbeat_lease`, and `tasks.lease` return the reusable artifact alias as `taskName` plus `taskTitle`, not the backend UUID. Use `taskName` for later Task operations; retain the lease token for heartbeat or release.
50
52
 
51
53
  ### Context Mesh persistence model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -102,10 +102,16 @@ const focusedCommand = buildCommand({
102
102
 
103
103
  function buildPauseUnpauseCommand(action: "pause" | "unpause") {
104
104
  return buildCommand({
105
- func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string }) {
105
+ func: async function (this: TaskContext, flags: { sessionId?: string; sessionSecret?: string; idempotencyKey?: string }) {
106
106
  const focus = await this.client.call<Record<string, unknown>, { artifact: CliArtifact; status: string }>(
107
107
  `tasks.${action}` as OperationName,
108
- { actor: "user", source: "cli", session_id: flags.sessionId, session_secret: flags.sessionSecret },
108
+ {
109
+ actor: "user",
110
+ source: "cli",
111
+ session_id: flags.sessionId,
112
+ session_secret: flags.sessionSecret,
113
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
114
+ },
109
115
  );
110
116
  render.call(this, focus, `Focused (${focus.status}): ${artifactLabel(focus.artifact)}`);
111
117
  },
@@ -119,6 +125,13 @@ function buildPauseUnpauseCommand(action: "pause" | "unpause") {
119
125
  placeholder: "secret",
120
126
  optional: true,
121
127
  },
128
+ idempotencyKey: {
129
+ brief: "Retry key for this exact mutation",
130
+ kind: "parsed",
131
+ parse: String,
132
+ placeholder: "key",
133
+ optional: true,
134
+ },
122
135
  },
123
136
  },
124
137
  docs: { brief: `${action === "pause" ? "Pause" : "Resume"} the active Task Focus` },
@@ -307,6 +320,21 @@ const eventFeedCommand = buildCommand({
307
320
  docs: { brief: "Feed of raw Task lifecycle events across every task" },
308
321
  });
309
322
 
323
+ const mutationStatusCommand = buildCommand({
324
+ func: async function (this: TaskContext, _flags: Record<string, never>, idempotencyKey: string) {
325
+ const receipt = await this.client.call<Record<string, unknown>, { receiptId: string; operation: string; state: string }>(
326
+ "tasks.mutation_status",
327
+ { idempotency_key: idempotencyKey },
328
+ );
329
+ render.call(this, receipt, `${receipt.operation}: ${receipt.state} (${receipt.receiptId})`);
330
+ },
331
+ parameters: {
332
+ flags: {},
333
+ positional: { kind: "tuple", parameters: [{ brief: "Original idempotency key", parse: String, placeholder: "key" }] },
334
+ },
335
+ docs: { brief: "Resolve an unknown Task lifecycle mutation outcome" },
336
+ });
337
+
310
338
  // -- CRUD -----------------------------------------------------------------------------------
311
339
 
312
340
  const createCommand = buildCommand({
@@ -788,12 +816,13 @@ const planCommand = buildCommand({
788
816
  });
789
817
 
790
818
  const completeCommand = buildCommand({
791
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
819
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
792
820
  const completion = await this.client.call<Record<string, unknown>, CliCompletion>("tasks.complete", {
793
821
  id,
794
822
  actor: "user",
795
823
  source: "cli",
796
824
  session_id: flags.sessionId,
825
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
797
826
  });
798
827
  const lines = [`${completion.completed ? "Completed" : "Rejected"}: ${artifactLabel(completion.artifact)}`];
799
828
  if (completion.focused) lines.push(`Active: ${artifactLabel(completion.focused)}`);
@@ -806,24 +835,31 @@ const completeCommand = buildCommand({
806
835
  render.call(this, completion, lines.join("\n"));
807
836
  },
808
837
  parameters: {
809
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
838
+ flags: {
839
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
840
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
841
+ },
810
842
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
811
843
  },
812
844
  docs: { brief: "Run gates and checklist review, then complete" },
813
845
  });
814
846
 
815
847
  const startCommand = buildCommand({
816
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
848
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
817
849
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>("tasks.start", {
818
850
  id,
819
851
  actor: "user",
820
852
  source: "cli",
821
853
  session_id: flags.sessionId,
854
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
822
855
  });
823
856
  render.call(this, artifact, `Started: ${artifactLabel(artifact)}`);
824
857
  },
825
858
  parameters: {
826
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
859
+ flags: {
860
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
861
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
862
+ },
827
863
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
828
864
  },
829
865
  docs: { brief: "Lifecycle transition: todo -> in-progress" },
@@ -831,17 +867,21 @@ const startCommand = buildCommand({
831
867
 
832
868
  function buildSimpleTransitionCommand(action: "submit" | "reject" | "retry" | "cancel" | "reopen", brief: string) {
833
869
  return buildCommand({
834
- func: async function (this: TaskContext, flags: { sessionId?: string }, id: string) {
870
+ func: async function (this: TaskContext, flags: { sessionId?: string; idempotencyKey?: string }, id: string) {
835
871
  const artifact = await this.client.call<Record<string, unknown>, CliArtifact>(`tasks.${action}` as OperationName, {
836
872
  id,
837
873
  actor: "user",
838
874
  source: "cli",
839
875
  session_id: flags.sessionId,
876
+ ...(flags.idempotencyKey ? { idempotency_key: flags.idempotencyKey } : {}),
840
877
  });
841
878
  render.call(this, artifact, `${action[0]!.toUpperCase()}${action.slice(1)}: ${artifactLabel(artifact)}`);
842
879
  },
843
880
  parameters: {
844
- flags: { sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true } },
881
+ flags: {
882
+ sessionId: { brief: "Scope to one agent session", kind: "parsed", parse: String, placeholder: "id", optional: true },
883
+ idempotencyKey: { brief: "Retry key for this exact mutation", kind: "parsed", parse: String, placeholder: "key", optional: true },
884
+ },
845
885
  positional: { kind: "tuple", parameters: [{ brief: "Task id", parse: String, placeholder: "id" }] },
846
886
  },
847
887
  docs: { brief },
@@ -920,6 +960,7 @@ const app = buildApplication(
920
960
  lease: leaseCommand,
921
961
  "reap-stale-leases": reapStaleLeasesCommand,
922
962
  "event-feed": eventFeedCommand,
963
+ "mutation-status": mutationStatusCommand,
923
964
  create: createCommand,
924
965
  list: listCommand,
925
966
  show: showCommand,
package/src/cli.ts CHANGED
@@ -162,8 +162,8 @@ const USAGE = `Usage:
162
162
  papyrus tasks graph [--session-id <id>] [--json]
163
163
  papyrus tasks active [--session-id <id>] [--json]
164
164
  papyrus tasks focused [--session-id <id>] [--json]
165
- papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--json]
166
- papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--json]
165
+ papyrus tasks pause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
166
+ papyrus tasks unpause [--session-id <id>] [--session-secret <secret>] [--idempotency-key <key>] [--json]
167
167
  papyrus tasks clear-focus [--session-id <id>] [--session-secret <secret>] [--json]
168
168
  papyrus tasks reap-stale-focus [--json]
169
169
  papyrus tasks claim <id> --owner <owner> [--ttl-ms <ms>] [--note <text>] [--json]
@@ -172,17 +172,19 @@ const USAGE = `Usage:
172
172
  papyrus tasks lease <id> [--json]
173
173
  papyrus tasks reap-stale-leases [--json]
174
174
  papyrus tasks event-feed [--cursor <n>] [--limit <n>] [--event-types-json <json>] [--json]
175
+ papyrus tasks mutation-status <idempotency-key> [--json]
175
176
  papyrus tasks history <id> [--json]
176
177
  papyrus tasks scope [project|all|graph <root-id>] [--json]
177
178
  papyrus tasks assign-project <id> [project-root] [--json]
178
179
  papyrus tasks focus <id> [--session-id <id>] [--session-secret <secret>] [--json]
179
180
  papyrus tasks update <id> [--title <title>] [--body <body>] [--labels-json <json>] [--status todo --reason <reason>] [--json]
180
- papyrus tasks complete <id> [--session-id <id>] [--json]
181
- papyrus tasks start <id> [--session-id <id>] [--json]
182
- papyrus tasks submit <id> [--session-id <id>] [--json]
183
- papyrus tasks reject <id> [--session-id <id>] [--json]
184
- papyrus tasks retry <id> [--session-id <id>] [--json]
185
- papyrus tasks cancel <id> [--session-id <id>] [--json]
181
+ papyrus tasks complete <id> [--session-id <id>] [--idempotency-key <key>] [--json]
182
+ papyrus tasks start <id> [--session-id <id>] [--idempotency-key <key>] [--json]
183
+ papyrus tasks submit <id> [--session-id <id>] [--idempotency-key <key>] [--json]
184
+ papyrus tasks reject <id> [--session-id <id>] [--idempotency-key <key>] [--json]
185
+ papyrus tasks retry <id> [--session-id <id>] [--idempotency-key <key>] [--json]
186
+ papyrus tasks cancel <id> [--session-id <id>] [--idempotency-key <key>] [--json]
187
+ papyrus tasks reopen <id> [--session-id <id>] [--idempotency-key <key>] [--json]
186
188
  papyrus tasks cancel-subtree <id> [--session-id <id>] [--json]
187
189
  papyrus tasks depend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
188
190
  papyrus tasks undepend <id> <prerequisite-id> [--reason <reason>] [--session-id <id>] [--json]
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@ export const DAEMON_PROBE_TIMEOUT_MS = 800;
9
9
  export const DAEMON_UNIT_NAME = "papyrus.service";
10
10
  export const DAEMON_DIR_ENV = "PAPYRUS_DAEMON_DIR";
11
11
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
12
- export const SQLITE_SCHEMA_VERSION = 26;
12
+ export const SQLITE_SCHEMA_VERSION = 27;
13
13
  export const SERVICE_MAX_BODY_BYTES = 1_048_576;
14
14
 
15
15
  export const WAL_CHECKPOINT_INTERVAL_MS = 60_000;
@@ -267,6 +267,8 @@ export const TASK_PROJECT_ALIAS_MAX_COUNT = 20;
267
267
  export const TASK_PROJECT_LIST_MAX_RESULTS = 100;
268
268
  export const TASK_CREATE_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
269
269
  export const TASK_CREATE_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
270
+ export const TASK_MUTATION_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
271
+ export const TASK_MUTATION_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
270
272
  export const GRAPH_RENDER_PADDING_X = 2;
271
273
  export const GRAPH_RENDER_PADDING_Y = 1;
272
274
  export const GRAPH_RENDER_BOX_PADDING = 0;
package/src/db.ts CHANGED
@@ -176,6 +176,24 @@ CREATE TABLE IF NOT EXISTS task_create_requests (
176
176
  PRIMARY KEY (request_scope, idempotency_key)
177
177
  );
178
178
  CREATE INDEX IF NOT EXISTS task_create_requests_expiry_idx ON task_create_requests(expires_at);
179
+ CREATE TABLE IF NOT EXISTS task_mutation_requests (
180
+ request_scope TEXT NOT NULL,
181
+ idempotency_key TEXT NOT NULL,
182
+ receipt_id TEXT NOT NULL UNIQUE,
183
+ task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
184
+ operation TEXT NOT NULL,
185
+ request_hash TEXT NOT NULL,
186
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
187
+ response_json TEXT,
188
+ created_at TEXT NOT NULL,
189
+ updated_at TEXT NOT NULL,
190
+ expires_at TEXT NOT NULL,
191
+ PRIMARY KEY (request_scope, idempotency_key),
192
+ CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
193
+ );
194
+ CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
195
+ CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
196
+ ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
179
197
  CREATE TABLE IF NOT EXISTS artifact_events (
180
198
  id INTEGER PRIMARY KEY AUTOINCREMENT,
181
199
  artifact_id TEXT NOT NULL REFERENCES artifacts(id),
@@ -799,6 +817,32 @@ const FUTURE_MIGRATIONS: ReadonlyArray<PapyrusMigration> = [
799
817
  }
800
818
  },
801
819
  },
820
+ {
821
+ version: 27,
822
+ name: "task-lifecycle-mutation-receipts",
823
+ up: (db) => {
824
+ db.exec(`
825
+ CREATE TABLE IF NOT EXISTS task_mutation_requests (
826
+ request_scope TEXT NOT NULL,
827
+ idempotency_key TEXT NOT NULL,
828
+ receipt_id TEXT NOT NULL UNIQUE,
829
+ task_id TEXT REFERENCES artifacts(id) ON DELETE CASCADE,
830
+ operation TEXT NOT NULL,
831
+ request_hash TEXT NOT NULL,
832
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
833
+ response_json TEXT,
834
+ created_at TEXT NOT NULL,
835
+ updated_at TEXT NOT NULL,
836
+ expires_at TEXT NOT NULL,
837
+ PRIMARY KEY (request_scope, idempotency_key),
838
+ CHECK ((state = 'pending' AND response_json IS NULL) OR (state = 'completed' AND response_json IS NOT NULL))
839
+ );
840
+ CREATE INDEX IF NOT EXISTS task_mutation_requests_expiry_idx ON task_mutation_requests(expires_at);
841
+ CREATE UNIQUE INDEX IF NOT EXISTS task_mutation_requests_pending_task_operation_idx
842
+ ON task_mutation_requests(task_id, operation) WHERE state = 'pending' AND task_id IS NOT NULL;
843
+ `);
844
+ },
845
+ },
802
846
  ];
803
847
 
804
848
  /**
@@ -107,9 +107,8 @@ export type TransitionTable<Action extends string, Status extends string> = Reco
107
107
  /**
108
108
  * The from/to-table lookup+validation half of a transition, split out from runTransition
109
109
  * (below) so a caller with its own side effects gated on "is this action even valid from the
110
- * current status" (e.g. Tasks.transition's dependency-blocking check and focus-store
111
- * bookkeeping, which must run -- or not -- before the status write itself) can call this
112
- * directly instead of duplicating the same lookup+throw three times. Every other caller
110
+ * current status" can call this directly instead of duplicating the same lookup+throw.
111
+ * Task lifecycle now uses its richer retry-safe transition primitive instead. Every other caller
113
112
  * (Document/Rule/Playbook, none of which have that ordering constraint) uses runTransition
114
113
  * instead, which does this same check plus the write in one call.
115
114
  */
@@ -44,6 +44,10 @@ import {
44
44
  } from "./shared.ts";
45
45
 
46
46
  const OWNER = "playbooks";
47
+ const jsonObjectProp = {
48
+ type: ["object", "string"],
49
+ description: "A JSON object; a JSON-encoded object string is also accepted for tool-calling compatibility.",
50
+ } as const;
47
51
 
48
52
  export interface PlaybooksVehicleDeps {
49
53
  artifacts: ArtifactStore;
@@ -126,7 +130,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
126
130
  "preview",
127
131
  "Renders a Playbook's whole composition tree as text, with no side effects.",
128
132
  "read",
129
- { id: stringProp, name: stringProp, arguments: { type: "object" } },
133
+ { id: stringProp, name: stringProp, arguments: jsonObjectProp },
130
134
  [],
131
135
  (input) => {
132
136
  normalizeJsonEncodedField(input, "arguments");
@@ -138,7 +142,7 @@ export function registerPlaybooksVehicleOperations(registry: VehicleRegistry, de
138
142
  "invoke",
139
143
  "Compiles the Playbook's steps and composition tree into real Tasks wired with dependsOn, and focuses the first one -- one step surfaces at a time as it becomes focused, exactly like any other Task. `arguments` supplies known values as {name: value}; if a declared REQUIRED argument is still missing, nothing is created and missingArguments is returned instead -- ask the human for these (discuss tool, live:true) and invoke again, never guess. Drive the returned entryTaskId forward with the tasks tool (start/submit/complete).",
140
144
  "local-write",
141
- { id: stringProp, name: stringProp, run_id: stringProp, arguments: { type: "object" }, project_root: stringProp },
145
+ { id: stringProp, name: stringProp, run_id: stringProp, arguments: jsonObjectProp, project_root: stringProp },
142
146
  [],
143
147
  (input) => {
144
148
  normalizeJsonEncodedField(input, "arguments");
@@ -7,11 +7,13 @@ import {
7
7
  bindVehicleOperation,
8
8
  defineVehicleOperation,
9
9
  defineVehicleSchema,
10
+ type JsonSchema,
10
11
  type VehicleContentBlock,
11
12
  VehicleError,
12
13
  type VehicleLimits,
13
14
  type VehicleOperationContext,
14
15
  type VehicleSchemaCodec,
16
+ type VehicleSchemaIssue,
15
17
  } from "@danypops/vehicle-core";
16
18
  import type { VehicleRegistry } from "@danypops/vehicle-server";
17
19
  import type { Artifact } from "../artifact/artifact.ts";
@@ -21,33 +23,118 @@ import { InvalidSessionSecretError } from "../session-identity/session-identity-
21
23
  import { TaskCreateIdempotencyConflictError } from "../stores/task-create-request-store.ts";
22
24
  import { TaskDependencyCycleError, TaskExecutionBoundExceededError, type TaskExecutionPlan } from "../task/task-execution.ts";
23
25
 
24
- /**
25
- * VehicleRegistry only ever calls a schema's own safeParse -- jsonSchema is
26
- * descriptive metadata surfaced to a client/Pi projection, never itself
27
- * enforced at runtime -- so a declared `enum` has to be checked here for
28
- * real, or it's a documentation gesture, not an honest contract.
29
- */
26
+ interface OperationSchemaNode {
27
+ readonly type?: string | readonly string[];
28
+ readonly enum?: readonly unknown[];
29
+ readonly properties?: Readonly<Record<string, OperationSchemaNode>>;
30
+ readonly required?: readonly string[];
31
+ readonly additionalProperties?: boolean | OperationSchemaNode;
32
+ readonly items?: OperationSchemaNode;
33
+ readonly minLength?: number;
34
+ readonly maxLength?: number;
35
+ readonly minimum?: number;
36
+ readonly maximum?: number;
37
+ readonly minItems?: number;
38
+ readonly maxItems?: number;
39
+ readonly description?: string;
40
+ readonly [key: string]: unknown;
41
+ }
42
+
43
+ function schemaIssue(path: readonly (string | number)[], message: string): VehicleSchemaIssue[] {
44
+ return [{ path, message }];
45
+ }
46
+
47
+ function matchesSchemaType(value: unknown, type: string): boolean {
48
+ if (type === "object") return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ if (type === "array") return Array.isArray(value);
50
+ if (type === "string") return typeof value === "string";
51
+ if (type === "number") return typeof value === "number" && Number.isFinite(value);
52
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value);
53
+ if (type === "boolean") return typeof value === "boolean";
54
+ return true;
55
+ }
56
+
57
+ function validateSchemaValue(value: unknown, schema: OperationSchemaNode, path: readonly (string | number)[]): VehicleSchemaIssue[] {
58
+ const label = path.length === 0 ? "input" : String(path.at(-1));
59
+ const declaredTypes = typeof schema.type === "string" ? [schema.type] : (schema.type ?? []);
60
+ const type = declaredTypes.find((candidate) => matchesSchemaType(value, candidate));
61
+ if (declaredTypes.length > 0 && type === undefined) {
62
+ const accepted = declaredTypes.map((candidate) =>
63
+ candidate === "integer" ? "an integer" : `${candidate === "object" ? "an" : "a"} ${candidate}`,
64
+ );
65
+ return schemaIssue(path, `${label} must be ${accepted.join(" or ")}`);
66
+ }
67
+ if (type === "object") {
68
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return schemaIssue(path, `${label} must be an object`);
69
+ const record = value as Record<string, unknown>;
70
+ for (const key of schema.required ?? []) {
71
+ if (!(key in record)) {
72
+ const acceptedShape = schema.description ? `; ${schema.description}` : "";
73
+ return schemaIssue([...path, key], `${key} is required${acceptedShape}`);
74
+ }
75
+ }
76
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
77
+ if (!(key in record)) continue;
78
+ const issues = validateSchemaValue(record[key], child, [...path, key]);
79
+ if (issues.length > 0) return issues;
80
+ }
81
+ for (const key of Object.keys(record)) {
82
+ if (key in (schema.properties ?? {})) continue;
83
+ if (schema.additionalProperties === false) return schemaIssue([...path, key], `${key} is not allowed`);
84
+ if (typeof schema.additionalProperties === "object") {
85
+ const issues = validateSchemaValue(record[key], schema.additionalProperties, [...path, key]);
86
+ if (issues.length > 0) return issues;
87
+ }
88
+ }
89
+ } else if (type === "array") {
90
+ const entries = value as unknown[];
91
+ if (schema.minItems !== undefined && entries.length < schema.minItems) {
92
+ return schemaIssue(path, `${label} must contain at least ${schema.minItems} item(s)`);
93
+ }
94
+ if (schema.maxItems !== undefined && entries.length > schema.maxItems) {
95
+ return schemaIssue(path, `${label} cannot contain more than ${schema.maxItems} item(s)`);
96
+ }
97
+ if (schema.items) {
98
+ for (const [index, entry] of entries.entries()) {
99
+ const issues = validateSchemaValue(entry, schema.items, [...path, index]);
100
+ if (issues.length > 0) return issues;
101
+ }
102
+ }
103
+ } else if (type === "string") {
104
+ const text = value as string;
105
+ if (schema.minLength !== undefined && text.length < schema.minLength) {
106
+ return schemaIssue(path, `${label} must contain at least ${schema.minLength} character(s)`);
107
+ }
108
+ if (schema.maxLength !== undefined && text.length > schema.maxLength) {
109
+ return schemaIssue(path, `${label} cannot exceed ${schema.maxLength} character(s)`);
110
+ }
111
+ } else if (type === "number" || type === "integer") {
112
+ const number = value as number;
113
+ if (schema.minimum !== undefined && number < schema.minimum) {
114
+ return schemaIssue(path, `${label} must be at least ${schema.minimum}`);
115
+ }
116
+ if (schema.maximum !== undefined && number > schema.maximum) {
117
+ return schemaIssue(path, `${label} cannot exceed ${schema.maximum}`);
118
+ }
119
+ }
120
+ if (schema.enum && !schema.enum.includes(value)) {
121
+ return schemaIssue(path, `${label} must be one of ${schema.enum.join(", ")}`);
122
+ }
123
+ return [];
124
+ }
125
+
126
+ /** VehicleRegistry executes this codec before resolving or dispatching an operation. Keep the
127
+ * recursive runtime checks aligned with the same JSON Schema clients and tools_man receive. */
30
128
  export function looseObjectSchema(
31
- properties: Record<string, { type: string; enum?: readonly string[] }>,
129
+ properties: Readonly<Record<string, OperationSchemaNode>>,
32
130
  required: readonly string[] = [],
33
131
  ): VehicleSchemaCodec<Record<string, unknown>> {
132
+ const schema = { type: "object", properties, required: [...required], additionalProperties: false } as const;
34
133
  return defineVehicleSchema<Record<string, unknown>>({
35
- jsonSchema: { type: "object", properties, required: [...required], additionalProperties: false },
134
+ jsonSchema: schema as unknown as JsonSchema,
36
135
  safeParse(value) {
37
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
38
- return { success: false, issues: [{ path: [], message: "input must be an object" }] };
39
- }
40
- const input = value as Record<string, unknown>;
41
- for (const key of required) {
42
- if (!(key in input)) return { success: false, issues: [{ path: [key], message: `${key} is required` }] };
43
- }
44
- for (const [key, schema] of Object.entries(properties)) {
45
- if (!schema.enum || !(key in input)) continue;
46
- if (!schema.enum.includes(input[key] as string)) {
47
- return { success: false, issues: [{ path: [key], message: `${key} must be one of ${schema.enum.join(", ")}` }] };
48
- }
49
- }
50
- return { success: true, value: input };
136
+ const issues = validateSchemaValue(value, schema, []);
137
+ return issues.length > 0 ? { success: false, issues } : { success: true, value: value as Record<string, unknown> };
51
138
  },
52
139
  });
53
140
  }
@@ -249,7 +336,7 @@ export function buildWorkflowRunContent(
249
336
 
250
337
  export type OperationSchemaProperties = Record<
251
338
  string,
252
- { type: string; enum?: readonly string[]; description?: string; [key: string]: unknown }
339
+ { type: string | readonly string[]; enum?: readonly string[]; description?: string; [key: string]: unknown }
253
340
  >;
254
341
 
255
342
  export type DefineOperation = (