@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 +2 -0
- package/package.json +1 -1
- package/src/cli/task-command.ts +49 -8
- package/src/cli.ts +10 -8
- package/src/constants.ts +3 -1
- package/src/db.ts +44 -0
- package/src/domain-service-shared.ts +2 -3
- package/src/handlers/playbooks.ts +6 -2
- package/src/handlers/shared.ts +110 -23
- package/src/handlers/tasks.ts +134 -56
- package/src/id-migration.ts +2 -0
- package/src/index.ts +9 -1
- package/src/modules/tasks.ts +33 -10
- package/src/service.ts +4 -1
- package/src/stores/sqlite-task-mutation-request-store.ts +98 -0
- package/src/stores/task-mutation-request-store.ts +89 -0
- package/src/task/task-service.ts +402 -55
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
package/src/cli/task-command.ts
CHANGED
|
@@ -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
|
-
{
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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 =
|
|
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"
|
|
111
|
-
*
|
|
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:
|
|
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:
|
|
145
|
+
{ id: stringProp, name: stringProp, run_id: stringProp, arguments: jsonObjectProp, project_root: stringProp },
|
|
142
146
|
[],
|
|
143
147
|
(input) => {
|
|
144
148
|
normalizeJsonEncodedField(input, "arguments");
|
package/src/handlers/shared.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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:
|
|
134
|
+
jsonSchema: schema as unknown as JsonSchema,
|
|
36
135
|
safeParse(value) {
|
|
37
|
-
|
|
38
|
-
|
|
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 = (
|