From 3975966c91078209b89f7c577937e0715f636bce Mon Sep 17 00:00:00 2001 From: MehmetScgn Date: Wed, 25 Mar 2026 13:57:10 +0200 Subject: [PATCH] feat: add dispatch run command --- plan-dispatch-run-single-action-execution.md | 396 +++++++++++++++++++ src/cli.ts | 2 + src/commands/run.ts | 336 ++++++++++++++++ src/execution/schema-coerce.ts | 172 ++++++++ src/job/inputs.ts | 2 +- src/job/next-actions.ts | 12 + test/run-cli.test.ts | 256 ++++++++++++ test/schema-coerce.test.ts | 127 ++++++ 8 files changed, 1302 insertions(+), 1 deletion(-) create mode 100644 plan-dispatch-run-single-action-execution.md create mode 100644 src/commands/run.ts create mode 100644 src/execution/schema-coerce.ts create mode 100644 test/run-cli.test.ts create mode 100644 test/schema-coerce.test.ts diff --git a/plan-dispatch-run-single-action-execution.md b/plan-dispatch-run-single-action-execution.md new file mode 100644 index 0000000..e478be9 --- /dev/null +++ b/plan-dispatch-run-single-action-execution.md @@ -0,0 +1,396 @@ +# Plan: `dispatch run` — Single Action Execution + +## Context + +The dispatch CLI currently requires a job case file (JSON with `scenario.steps`) to execute any action. This is heavy for the primary use case: an AI agent (or GUI) wants to call one action, see the result, and move on. `dispatch run` acts like Postman — point at an action, pass inputs, get output. It is also phase 1 of a future GUI: the action's Zod schema defines "form fields", `dispatch run` is the execution backend. + +--- + +## Command Interface + +``` +dispatch run \ + --input status=PREMATCH \ + --input items='[{"id":3743}]' \ + --base-url https://example.com \ + --header x-brand=example \ + --credential token=API_TOKEN +``` + +--- + +## JSON Output Contract + +`dispatch run` follows the same machine-output envelope contract as `dispatch job run`. Agents already depend on `cliVersion`, `runId`, `runDir`, and `next[]` in the envelope. + +### Success (`--json`) + +```json +{ + "cliVersion": "0.0.1", + "action": "jsonplaceholder.list-posts", + "status": "SUCCESS", + "runId": "20260325-091245-action-run-abc1", + "runDir": "~/.dispatch/run-output/20260325-091245-action-run-abc1", + "response": { ... }, + "exports": { ... }, + "detail": "Listed 3 posts", + "next": [ + { + "command": "dispatch job readable --run-id 20260325-091245-action-run-abc1", + "description": "full request/response trace" + } + ] +} +``` + +Fields `response`, `exports`, `detail` come from `ActionResult`. Fields `cliVersion`, `status`, `runId`, `runDir`, `next` match the envelope contract. + +### Failure (`--json`) + +Uses the standard `jsonErrorEnvelope()`: +```json +{ + "status": "error", + "code": "RUNTIME_ERROR", + "retryable": false, + "message": "...", + "details": { "runId": "...", "runDir": "..." }, + "next": [ + { + "command": "dispatch job readable --run-id ...", + "description": "full request/response trace" + } + ] +} +``` + +### Next actions for `dispatch run` + +New function `nextActionsForActionRun()` in `src/job/next-actions.ts`: + +| Outcome | Next actions | +|---------|-------------| +| SUCCESS | `dispatch job readable --run-id {runId}` — view trace | +| FAILURE | `dispatch job readable --run-id {runId}` — view trace | + +Kept minimal — a single action in both cases since there's no assert/replay flow for standalone runs. + +--- + +## Input Coercion Model + +`--input` flags are flat `key=value` pairs. Coercion operates on **top-level schema properties only**: + +- Scalar top-level fields (`string`, `number`, `boolean`) are coerced from the string value. +- Complex top-level fields (`array`, `object`) must be passed as JSON strings: `--input items='[1,2,3]'`. +- Nested addressing (e.g. `--input foo.bar=1`) is **not supported** — the `.` is treated as part of the key name, which Zod will reject if the schema doesn't declare it. + +This is an intentional simplification. The future GUI phase will have field-level structured input via the Zod schema; the CLI is for quick scripted calls where JSON-in-a-flag is acceptable. + +--- + +## Run Artifacts + +`dispatch run` is **not a job run** — it has no job case, no multi-step orchestration, no interpolation. But it produces run artifacts that integrate with existing tooling (`dispatch job list`, `dispatch job latest`, `dispatch job readable`, `dispatch job inspect`). + +| File | Written by | When | Purpose | +|------|-----------|------|---------| +| `meta.json` | `run.ts` (explicit `writeJson`) | Before handler call | Run identity | +| `module_resolution.json` | `run.ts` (explicit `writeJson`) | Before handler call | Module audit | +| `summary.json` | `run.ts` (explicit `writeJson`) | After handler returns | Makes run visible to `dispatch job latest` / `dispatch job list` | +| `activity.log` | `RunArtifacts` (via `ctx.artifacts.appendActivity()`) | During execution | Human timeline | +| `http_calls.jsonl` | `HttpTransportImpl` (automatic) | During execution | HTTP trace | +| `calls/*.json` | `HttpTransportImpl` (automatic) | During execution | Request/response bodies | + +**Not written** (job-specific, not applicable): +- `job.case.input.json` — no job case +- `job.case.resolved.json` — no interpolation +- `step-results.json` — single step, result is in CLI output + +`meta.json` shape: +```json +{ + "cliVersion": "0.0.1", + "action": "jsonplaceholder.list-posts", + "runId": "20260325-091245-action-run-abc1", + "startedAt": "2026-03-25T09:12:45.000Z" +} +``` + +`summary.json` shape (compatible with `RunSummaryRecord` consumed by `dispatch job latest`/`dispatch job list`): +```json +{ + "runId": "20260325-091245-action-run-abc1", + "runDir": "~/.dispatch/run-output/20260325-091245-action-run-abc1", + "jobType": "action-run:jsonplaceholder.list-posts", + "startedAt": "2026-03-25T09:12:45.000Z", + "status": "SUCCESS" +} +``` + +`jobType` is set to `action-run:` so listing output distinguishes action runs from job runs while remaining a valid string for existing tooling (all `RunSummaryRecord` fields are optional strings). + +`module_resolution.json` shape: same as job runs — `generatedAt`, `warnings`, `conflicts`, `loadedModules`, plus a single-element `steps` array. + +--- + +## Module Discovery + +Job commands pass `searchFrom: [path.dirname(casePath)]` to `loadModuleRegistry()` so workspace-local modules resolve when invoked from outside the repo root. `dispatch run` has no case file path to anchor from. + +Strategy: call `loadModuleRegistry()` with no `searchFrom` override. It defaults to `[process.cwd(), ROOT_DIR]`, which covers: +- Running from the workspace root (standard agent/developer workflow) +- User-installed modules in `~/.dispatch/modules/` +- Builtin modules (always loaded) + +**Known limitation:** `dispatch run mymod.action` invoked from outside the workspace will **not** find repo-local modules, even though `dispatch job run --case /abs/path/to/case.json` would (because it anchors discovery to the case file's directory). This is an intentional phase-1 simplification — there is no case file to anchor from. Workarounds: +- `cd` into the workspace before running +- Install the module to `~/.dispatch/modules/` (user layer) + +If this becomes a friction point, a future `--workspace ` flag can be added to pass as `searchFrom`. + +--- + +## Files to Create/Modify + +### 1. `src/execution/schema-coerce.ts` (NEW) + +Schema-driven coercion: takes flat `Record` from `--input` and the action's Zod schema, returns a typed payload. + +```ts +export function coerceInputsFromSchema( + rawInputs: Record, + schema: z.ZodSchema +): { payload: Record; issues: string[] } +``` + +**Flow:** +1. Convert schema to JSON Schema via `schemaToJsonSchema()` from `src/modules/schema-contracts.ts` +2. For each input key, resolve the effective type of the **top-level** property from JSON Schema `.properties` +3. Coerce based on resolved type: + - `"string"` → keep as-is + - `"number"` / `"integer"` → `Number(value)`, reject if `NaN` + - `"boolean"` → `"true"` / `"false"` literal match + - `"array"` or `"object"` → `JSON.parse(value)`, report parse errors + - unknown/missing property → try `JSON.parse`, fallback to string (let Zod catch unknown keys) +4. Collect all coercion issues but continue (don't fail on first error) + +**Type resolution helper** — `resolveEffectiveType(node)`: + +JSON Schema from `z.toJSONSchema()` isn't always a flat `{ type: "string" }`. Handle: +- Direct `.type` field → return it +- `.enum` array present → infer `"string"` (or `"number"` if all values are numeric) +- `.anyOf` / `.oneOf` → filter out `{ not: {} }` (Zod's encoding of optional), return the type of the remaining variant. If multiple real variants remain, return `"unknown"` (let JSON.parse + fallback handle it) +- `.const` → infer from the value's JS type +- No type info → `"unknown"` + +### 2. `src/commands/run.ts` (NEW) + +New command handler. + +```ts +export function registerRunCommand( + program: Command, + deps: { cliVersion: string }, +): void +``` + +**Command definition:** +- `dispatch run ` — positional arg for `module.action` +- `--input ` — repeatable +- `--base-url ` — HTTP base URL +- `--header ` — repeatable, HTTP headers +- `--credential ` — repeatable, direct env-var credential mapping + +`collectRepeatedOption` is inlined (3-line function, not worth extracting from `job.ts`): +```ts +function collectRepeatedOption(value: string, previous: string[] = []): string[] { + previous.push(value); + return previous; +} +``` + +**Handler flow:** + +``` + 1. Parse global opts (--json, --verbose, --color) + 2. loadModuleRegistry() → resolve action, fail fast if not found + 3. parseRawInputs(inputFlags) → Record + 4. coerceInputsFromSchema(rawInputs, action.schema) → typed payload + 5. loadActionDefaults() → defaultsMap + 6. applyActionDefaults(actionKey, coercedPayload, defaultsMap) → merged payload + 7. action.schema.safeParse(merged) → validate, fail with issues if invalid + 8. Resolve credentials (if action has credentialSchema): + a. Parse --credential pairs into { field: envVarName } + b. Read env vars, fail if missing + c. Validate against credentialSchema.safeParse() + d. If action requires credentials and none passed → fail with helpful message + e. If --credential passed but action has no credentialSchema → warn and ignore + 9. Create RunArtifacts('action-run') +10. Write meta.json: { cliVersion, action: actionKey, runId, startedAt } +11. Write module_resolution.json: { generatedAt, warnings, conflicts, loadedModules, steps: [single entry] } +12. Create HttpTransportImpl(artifacts, { baseUrl, defaultHeaders, poolRegistry: getDefaultHttpPoolRegistry() }) +13. Create RuntimeContext via defaultRuntime(cliVersion) +14. Synthesize JobStep: { id: 'run', action: actionKey, payload: mergedPayload } +15. Build ActionContext: + { + http, + artifacts, + runtime, + step, + credential, + resolve: registry.resolve.bind(registry), + } +16. Call handler in try/catch: + - success → write summary.json (status: SUCCESS), build envelope, compute next actions, render + - throw → write summary.json (status: FAILED), wrap in cliErrorFromCode(), include runId/runDir in details, compute next actions, render via jsonErrorEnvelope() +17. Render result: + - --json: full envelope (see JSON Output Contract above) + - human: brief summary line + JSON response body +``` + +**Key reuse points:** +- `parseRawInputs()` from `src/job/inputs.ts` (newly exported) +- `applyActionDefaults()` + `loadActionDefaults()` from `src/execution/action-defaults.ts` +- `loadModuleRegistry()` from `src/modules/index.ts` +- `buildResolutionRow()` from `src/modules/conflicts.ts` +- `defaultRuntime()` from `src/data/run-data.ts` +- `RunArtifacts` from `src/artifacts/run-artifacts.ts` +- `HttpTransportImpl` from `src/transport/http.ts` +- `getDefaultHttpPoolRegistry()` from `src/services/http-pool.ts` +- `writeJson()` from `src/utils/fs-json.ts` +- `nowIso()` from `src/core/time.ts` +- `cliErrorFromCode()`, `jsonErrorEnvelope()`, `exitCodeForCliError()` from `src/core/errors.ts` +- `createRenderer`, `isColorEnabled`, `paint` from `src/output/renderer.ts` + +### 3. `src/job/inputs.ts` (MODIFY) + +Export `parseRawInputs`: + +```diff +-function parseRawInputs(rawInputs: string[]): ... ++export function parseRawInputs(rawInputs: string[]): ... +``` + +Single keyword change. No other modifications. + +### 4. `src/job/next-actions.ts` (MODIFY) + +Add `nextActionsForActionRun`: + +```ts +export function nextActionsForActionRun(input: { + runId: string; +}): NextAction[] { + return renderNextActions( + [ + { + command: 'dispatch job readable --run-id {runId}', + description: 'full request/response trace', + }, + ], + { runId: input.runId }, + ); +} +``` + +Follows the existing pattern of `nextActionsForJobRun`, `nextActionsForJobAssert`, `nextActionsForRunMany`. + +### 5. `src/cli.ts` (MODIFY) + +Wire up the new command: + +```diff ++import { registerRunCommand } from './commands/run.js'; + ... + registerJobCommands(program, { cliVersion: CLI_VERSION }); ++registerRunCommand(program, { cliVersion: CLI_VERSION }); + registerModuleCommands(program, { cliVersion: CLI_VERSION }); +``` + +--- + +## Credential Handling + +Jobs use named profiles (`credentials.myProfile.fromEnv`). Standalone runs simplify: + +``` +dispatch run api.create-user \ + --credential token=API_TOKEN \ + --credential secret=API_SECRET +``` + +Maps to `{ token: process.env.API_TOKEN, secret: process.env.API_SECRET }`, validated against `action.credentialSchema`. + +| Scenario | Behavior | +|----------|----------| +| Action has `credentialSchema`, `--credential` provided | Resolve env vars, validate, pass as `ctx.credential` | +| Action has `credentialSchema`, no `--credential` | Fail with message showing required fields | +| No `credentialSchema`, `--credential` provided | Warn and ignore | +| No `credentialSchema`, no `--credential` | Normal — no credential needed | + +--- + +## What We Skip + +- No interpolation (`${step.X}` — single step, no references) +- No capture +- No scenario/steps wrapping +- No job-level validation (no job file) +- No scheduling (`atRelative`/`atAbsolute`) +- No nested input addressing (`--input foo.bar=1` is not supported) +- No `--workspace` flag (CWD-based module discovery is sufficient for phase 1; see Module Discovery section) +- No outside-workspace module resolution (unlike job commands which anchor from case file path) + +--- + +## Implementation Order + +1. Export `parseRawInputs` from `src/job/inputs.ts` +2. Add `nextActionsForActionRun` to `src/job/next-actions.ts` +3. Create `src/execution/schema-coerce.ts` with `coerceInputsFromSchema()` + `resolveEffectiveType()` +4. Create `src/commands/run.ts` with `registerRunCommand()` +5. Wire up in `src/cli.ts` +6. Tests + +--- + +## Verification + +1. **Unit test `coerceInputsFromSchema()`** — cover: + - Simple types: string, number, boolean + - Enum schemas (Zod `z.enum()`) + - Optional fields (`z.string().optional()` — `anyOf` unwrapping) + - Array and object values via JSON.parse (top-level only) + - Coercion failure cases (NaN, bad boolean, invalid JSON) + - Unknown keys (should pass through, let Zod reject) + +2. **Unit test `resolveEffectiveType()`** — cover: + - Direct `{ type: "string" }` + - `{ enum: ["A", "B"] }` → string + - `{ anyOf: [{ type: "string" }, { not: {} }] }` → string (optional unwrapping) + - `{ anyOf: [{ type: "string" }, { type: "number" }] }` → unknown (ambiguous) + - No type info → unknown + +3. **Integration test `dispatch run`** against a test module: + ``` + dispatch run jsonplaceholder.list-posts --input userId=1 --input limit=3 --json + ``` + Verify the JSON envelope includes `cliVersion`, `status`, `runId`, `runDir`, `next[]`, `response`. + +4. **Artifact verification** — after a run, check that `meta.json`, `module_resolution.json`, and `summary.json` exist in the run directory with expected fields. Verify `dispatch job list` shows the action run with `jobType` = `action-run:`. + +5. **Credential test** — action with `credentialSchema`, verify env var resolution and validation. + +6. **Error cases:** + - Unknown action → NOT_FOUND with `next: []` + - Missing required fields → USAGE_ERROR with Zod issues + - Coercion failures → USAGE_ERROR with details + - Missing `--base-url` when handler makes HTTP call → clear error + - Missing required credentials → USAGE_ERROR listing required fields + - Handler throws → RUNTIME_ERROR or TRANSIENT_ERROR (inferred from message), includes `runId`/`runDir` in details and `next[]` + +7. **JSON contract test** — verify `--json` output shape matches the documented envelope (has `cliVersion`, `status`, `runId`, `runDir`, `next`). + +8. **Regression** — run existing test suite to confirm no breakage. diff --git a/src/cli.ts b/src/cli.ts index 72c9645..0597f0f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import fs from 'node:fs'; import packageJson from '../package.json' with { type: 'json' }; import { registerJobCommands } from './commands/job.js'; +import { registerRunCommand } from './commands/run.js'; import { registerModuleCommands } from './commands/module.js'; import { registerSkillCommands } from './commands/skill.js'; import { registerDoctorCommand } from './commands/doctor.js'; @@ -51,6 +52,7 @@ async function main(): Promise { }); registerJobCommands(program, { cliVersion: CLI_VERSION }); + registerRunCommand(program, { cliVersion: CLI_VERSION }); registerModuleCommands(program, { cliVersion: CLI_VERSION }); registerSkillCommands(program); registerDoctorCommand(program, { cliVersion: CLI_VERSION }); diff --git a/src/commands/run.ts b/src/commands/run.ts new file mode 100644 index 0000000..46a65c0 --- /dev/null +++ b/src/commands/run.ts @@ -0,0 +1,336 @@ +import path from 'node:path'; +import { Command } from 'commander'; +import { createRenderer, isColorEnabled, paint } from '../output/renderer.js'; +import { cliErrorFromCode, exitCodeForCliError, jsonErrorEnvelope } from '../core/errors.js'; +import { loadModuleRegistry } from '../modules/index.js'; +import { parseRawInputs } from '../job/inputs.js'; +import { coerceInputsFromSchema } from '../execution/schema-coerce.js'; +import { applyActionDefaults, loadActionDefaults } from '../execution/action-defaults.js'; +import { RunArtifacts } from '../artifacts/run-artifacts.js'; +import { HttpTransportImpl } from '../transport/http.js'; +import { getDefaultHttpPoolRegistry } from '../services/http-pool.js'; +import { defaultRuntime } from '../data/run-data.js'; +import { nowIso } from '../core/time.js'; +import { writeJson } from '../utils/fs-json.js'; +import { buildResolutionRow } from '../modules/conflicts.js'; +import { nextActionsForActionRun } from '../job/next-actions.js'; +import type { JobStep } from '../core/schema.js'; + +type CliOpts = { + json?: boolean; + verbose?: boolean; + color?: boolean; +}; + +interface ActionRunSummary { + cliVersion: string; + action: string; + status: 'SUCCESS' | 'FAILED'; + runId: string; + runDir: string; + response?: unknown; + exports?: Record; + detail?: string | null; +} + +function collectRepeatedOption(value: string, previous: string[] = []): string[] { + previous.push(value); + return previous; +} + +function parseKeyValueList( + entries: string[] | undefined, + label: 'header' | 'credential', +): { values: Record; issues: Array<{ path: string; message: string }> } { + const values: Record = {}; + const issues: Array<{ path: string; message: string }> = []; + + for (const raw of entries ?? []) { + const trimmed = String(raw ?? '').trim(); + const eqIndex = trimmed.indexOf('='); + if (!trimmed || eqIndex <= 0) { + issues.push({ + path: label, + message: `${label} '${raw}' must use key=value format`, + }); + continue; + } + const key = trimmed.slice(0, eqIndex).trim(); + const value = trimmed.slice(eqIndex + 1).trim(); + if (!key) { + issues.push({ + path: label, + message: `${label} '${raw}' must include a non-empty key`, + }); + continue; + } + if (Object.prototype.hasOwnProperty.call(values, key)) { + issues.push({ + path: `${label}.${key}`, + message: `${label} '${key}' was provided more than once`, + }); + continue; + } + values[key] = value; + } + + return { values, issues }; +} + +function resolveCredential( + actionKey: string, + credentialSchema: { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: Array; message: string }> } } } | undefined, + mappings: Record, +): { credential?: unknown; issues: Array<{ path: string; message: string }>; warnings: string[] } { + const issues: Array<{ path: string; message: string }> = []; + const warnings: string[] = []; + + if (!credentialSchema) { + if (Object.keys(mappings).length > 0) { + warnings.push(`Action '${actionKey}' does not declare a credential contract; ignoring --credential values`); + } + return { issues, warnings }; + } + + if (Object.keys(mappings).length === 0) { + issues.push({ + path: 'credential', + message: `Action '${actionKey}' requires credentials; pass --credential `, + }); + return { issues, warnings }; + } + + const resolved: Record = {}; + for (const [field, envName] of Object.entries(mappings)) { + const value = process.env[envName]; + if (value === undefined || value === '') { + issues.push({ + path: `credential.${field}`, + message: `Missing required environment variable '${envName}'`, + }); + continue; + } + resolved[field] = value; + } + + if (issues.length > 0) return { issues, warnings }; + + const parsed = credentialSchema.safeParse(resolved); + if (!parsed.success) { + for (const issue of parsed.error?.issues ?? []) { + issues.push({ + path: issue.path.length > 0 ? `credential.${issue.path.join('.')}` : 'credential', + message: issue.message, + }); + } + return { issues, warnings }; + } + + return { + credential: parsed.data, + issues, + warnings, + }; +} + +export function registerRunCommand( + program: Command, + deps: { cliVersion: string }, +): void { + program + .command('run') + .description('Run one action directly') + .argument('', 'Action key in format') + .option('--input ', 'Action input field', collectRepeatedOption, []) + .option('--base-url ', 'HTTP base URL') + .option('--header ', 'HTTP header', collectRepeatedOption, []) + .option('--credential ', 'Credential field mapping', collectRepeatedOption, []) + .action(async (actionKey: string, cmd) => { + const opts = program.opts(); + const color = isColorEnabled(opts); + const renderer = createRenderer({ json: !!opts.json, color }); + const { registry, warnings: registryWarnings } = await loadModuleRegistry(); + const resolved = registry.resolve(String(actionKey)); + + if (!resolved) { + const err = cliErrorFromCode('NOT_FOUND', `Unknown action '${actionKey}'`); + renderer.render({ + json: jsonErrorEnvelope(err), + human: `Error: ${err.message}`, + }); + process.exitCode = exitCodeForCliError(err); + return; + } + + const rawInputResult = parseRawInputs(cmd.input); + const headerResult = parseKeyValueList(cmd.header, 'header'); + const credentialMapResult = parseKeyValueList(cmd.credential, 'credential'); + const coerced = coerceInputsFromSchema(rawInputResult.values, resolved.definition.schema); + const payload = applyActionDefaults(resolved.actionKey, coerced.payload, loadActionDefaults()); + const payloadValidation = resolved.definition.schema.safeParse(payload); + const preflightIssues = [ + ...rawInputResult.issues, + ...headerResult.issues, + ...credentialMapResult.issues, + ...coerced.issues, + ]; + + if (preflightIssues.length > 0 || !payloadValidation.success) { + const zodIssues = payloadValidation.success + ? [] + : payloadValidation.error.issues.map((issue) => ({ + path: issue.path.length > 0 ? `inputs.${issue.path.join('.')}` : 'inputs', + message: issue.message, + })); + const err = cliErrorFromCode('USAGE_ERROR', 'action input preflight failed', { + action: resolved.actionKey, + issues: [...preflightIssues, ...zodIssues], + warnings: registryWarnings, + }); + renderer.render({ + json: jsonErrorEnvelope(err), + human: [ + `✗ Invalid inputs for ${resolved.actionKey}`, + ...[...preflightIssues, ...zodIssues].map((issue) => `- ${issue.path}: ${issue.message}`), + ], + }); + process.exitCode = exitCodeForCliError(err); + return; + } + + const credentialResult = resolveCredential( + resolved.actionKey, + resolved.definition.credentialSchema as + | { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: Array; message: string }> } } } + | undefined, + credentialMapResult.values, + ); + const warnings = [...registryWarnings, ...credentialResult.warnings]; + + if (credentialResult.issues.length > 0) { + const err = cliErrorFromCode('USAGE_ERROR', 'action credential preflight failed', { + action: resolved.actionKey, + issues: credentialResult.issues, + warnings, + }); + renderer.render({ + json: jsonErrorEnvelope(err), + human: [ + `✗ Missing credentials for ${resolved.actionKey}`, + ...credentialResult.issues.map((issue) => `- ${issue.path}: ${issue.message}`), + ], + }); + process.exitCode = exitCodeForCliError(err); + return; + } + + const artifacts = new RunArtifacts('action-run'); + const runtime = defaultRuntime(deps.cliVersion); + runtime.run.startedAt = nowIso(); + const step: JobStep = { id: 'run', action: resolved.actionKey, payload }; + const summaryBase = { + cliVersion: deps.cliVersion, + action: resolved.actionKey, + runId: artifacts.runId, + runDir: artifacts.runDir, + }; + + writeJson(path.join(artifacts.runDir, 'meta.json'), { + cliVersion: deps.cliVersion, + action: resolved.actionKey, + runId: artifacts.runId, + startedAt: runtime.run.startedAt, + }); + writeJson(path.join(artifacts.runDir, 'module_resolution.json'), { + generatedAt: nowIso(), + warnings, + conflicts: registry.listConflicts(), + loadedModules: registry.listModules().map((mod) => ({ + name: mod.name, + version: mod.version, + layer: mod.layer, + sourcePath: mod.sourcePath, + actions: Object.keys(mod.actions).map((name) => `${mod.name}.${name}`), + })), + steps: [buildResolutionRow(step.id, step.action, resolved)], + }); + + const http = new HttpTransportImpl(artifacts, { + baseUrl: typeof cmd.baseUrl === 'string' ? cmd.baseUrl : undefined, + defaultHeaders: headerResult.values, + poolRegistry: getDefaultHttpPoolRegistry(), + }); + + const writeSummary = (status: 'SUCCESS' | 'FAILED') => { + writeJson(path.join(artifacts.runDir, 'summary.json'), { + runId: artifacts.runId, + runDir: artifacts.runDir, + jobType: `action-run:${resolved.actionKey}`, + startedAt: runtime.run.startedAt, + status, + }); + }; + + try { + const result = await resolved.definition.handler( + { + http, + artifacts, + runtime, + step, + credential: credentialResult.credential, + resolve: (nextActionKey: string) => registry.resolve(nextActionKey), + }, + payloadValidation.data, + ); + + if (resolved.definition.exportsSchema) { + const exportsParseResult = resolved.definition.exportsSchema.safeParse(result.exports ?? {}); + if (!exportsParseResult.success) { + const message = exportsParseResult.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; '); + throw new Error(`Export validation failed for ${resolved.actionKey}: ${message}`); + } + } + + writeSummary('SUCCESS'); + const next = nextActionsForActionRun({ runId: artifacts.runId }); + const summary: ActionRunSummary = { + ...summaryBase, + status: 'SUCCESS', + response: result.response, + exports: result.exports, + detail: result.detail ?? null, + }; + + renderer.render({ + json: { ...summary, next }, + human: [ + `${paint('✓', 'success', color)} ${resolved.actionKey}${result.detail ? ` ${result.detail}` : ''}`, + ...(warnings.map((warning) => `${paint('!', 'warning', color)} ${warning}`)), + ...(result.response === undefined ? [] : [JSON.stringify(result.response, null, 2)]), + ], + }); + } catch (error) { + writeSummary('FAILED'); + const next = nextActionsForActionRun({ runId: artifacts.runId }); + const code = exitCodeForCliError(error) === 3 ? 'TRANSIENT_ERROR' : 'RUNTIME_ERROR'; + const err = cliErrorFromCode(code, error instanceof Error ? error.message : String(error), { + action: resolved.actionKey, + runId: artifacts.runId, + runDir: artifacts.runDir, + warnings, + }); + renderer.render({ + json: jsonErrorEnvelope(err, next), + human: [ + `Error: ${err.message}`, + ...warnings.map((warning) => `${paint('!', 'warning', color)} ${warning}`), + ...next.map((action) => `- ${action.description}: ${action.command}`), + ], + }); + process.exitCode = exitCodeForCliError(err); + } + }); +} diff --git a/src/execution/schema-coerce.ts b/src/execution/schema-coerce.ts new file mode 100644 index 0000000..6ffa75c --- /dev/null +++ b/src/execution/schema-coerce.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; +import { schemaToJsonSchema } from '../modules/schema-contracts.js'; + +type JsonSchemaNode = Record; + +export interface SchemaCoerceIssue { + path: string; + message: string; +} + +type EffectiveType = 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' | 'unknown'; + +export function coerceInputsFromSchema( + rawInputs: Record, + schema: z.ZodSchema, +): { payload: Record; issues: SchemaCoerceIssue[] } { + const payload: Record = {}; + const issues: SchemaCoerceIssue[] = []; + const jsonSchema = schemaToJsonSchema(schema); + const properties = isJsonObject(jsonSchema?.properties) ? (jsonSchema.properties as Record) : {}; + + for (const [key, rawValue] of Object.entries(rawInputs)) { + const propertySchema = isJsonObject(properties[key]) ? (properties[key] as JsonSchemaNode) : null; + const type = resolveEffectiveType(propertySchema); + + if (type === 'string') { + payload[key] = rawValue; + continue; + } + + if (type === 'number' || type === 'integer') { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + issues.push({ + path: `inputs.${key}`, + message: `Input '${key}' must be a finite ${type === 'integer' ? 'integer' : 'number'}`, + }); + continue; + } + payload[key] = type === 'integer' ? Math.trunc(value) : value; + if (type === 'integer' && !Number.isInteger(value)) { + issues.push({ + path: `inputs.${key}`, + message: `Input '${key}' must be an integer`, + }); + delete payload[key]; + } + continue; + } + + if (type === 'boolean') { + if (rawValue === 'true') { + payload[key] = true; + continue; + } + if (rawValue === 'false') { + payload[key] = false; + continue; + } + issues.push({ + path: `inputs.${key}`, + message: `Input '${key}' must be 'true' or 'false'`, + }); + continue; + } + + if (type === 'array' || type === 'object') { + const parsed = parseJsonInput(key, rawValue, type); + if (!parsed.ok) { + issues.push(parsed.issue); + continue; + } + payload[key] = parsed.value; + continue; + } + + payload[key] = parseUnknownInput(rawValue); + } + + return { payload, issues }; +} + +export function resolveEffectiveType(node: JsonSchemaNode | null | undefined): EffectiveType { + if (!isJsonObject(node)) return 'unknown'; + + if (typeof node.type === 'string') return normalizeType(node.type); + if (Array.isArray(node.type)) { + const types = node.type.map((value) => normalizeType(value)).filter((value) => value !== 'unknown'); + return types.length === 1 ? types[0] : 'unknown'; + } + + if (Array.isArray(node.enum) && node.enum.length > 0) { + if (node.enum.every((value) => typeof value === 'number')) return 'number'; + if (node.enum.every((value) => typeof value === 'boolean')) return 'boolean'; + return 'string'; + } + + if (Object.prototype.hasOwnProperty.call(node, 'const')) { + return normalizeConstType(node.const); + } + + for (const key of ['anyOf', 'oneOf'] as const) { + const variants = Array.isArray(node[key]) ? node[key].filter((value) => !isOptionalSentinel(value)) : []; + if (variants.length !== 1 || !isJsonObject(variants[0])) continue; + return resolveEffectiveType(variants[0] as JsonSchemaNode); + } + + return 'unknown'; +} + +function parseJsonInput( + key: string, + rawValue: string, + expectedType: 'array' | 'object', +): { ok: true; value: unknown } | { ok: false; issue: SchemaCoerceIssue } { + try { + const parsed = JSON.parse(rawValue); + const typeMatches = + (expectedType === 'array' && Array.isArray(parsed)) || + (expectedType === 'object' && !!parsed && typeof parsed === 'object' && !Array.isArray(parsed)); + if (!typeMatches) { + return { + ok: false, + issue: { + path: `inputs.${key}`, + message: `Input '${key}' must be valid JSON ${expectedType}`, + }, + }; + } + return { ok: true, value: parsed }; + } catch { + return { + ok: false, + issue: { + path: `inputs.${key}`, + message: `Input '${key}' must be valid JSON ${expectedType}`, + }, + }; + } +} + +function parseUnknownInput(rawValue: string): unknown { + try { + return JSON.parse(rawValue); + } catch { + return rawValue; + } +} + +function normalizeType(value: unknown): EffectiveType { + if (value === 'string' || value === 'number' || value === 'integer' || value === 'boolean' || value === 'array' || value === 'object') { + return value; + } + return 'unknown'; +} + +function normalizeConstType(value: unknown): EffectiveType { + if (typeof value === 'string') return 'string'; + if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number'; + if (typeof value === 'boolean') return 'boolean'; + if (Array.isArray(value)) return 'array'; + if (value && typeof value === 'object') return 'object'; + return 'unknown'; +} + +function isOptionalSentinel(value: unknown): boolean { + return isJsonObject(value) && isJsonObject(value.not) && Object.keys(value.not).length === 0; +} + +function isJsonObject(value: unknown): value is JsonSchemaNode { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/job/inputs.ts b/src/job/inputs.ts index 612ae36..16c296d 100644 --- a/src/job/inputs.ts +++ b/src/job/inputs.ts @@ -12,7 +12,7 @@ interface ResolvedJobInputsResult { values: JsonObject; } -function parseRawInputs(rawInputs: string[]): { values: Record; issues: JobInputIssue[] } { +export function parseRawInputs(rawInputs: string[]): { values: Record; issues: JobInputIssue[] } { const values: Record = {}; const issues: JobInputIssue[] = []; diff --git a/src/job/next-actions.ts b/src/job/next-actions.ts index 9dc3026..4b14625 100644 --- a/src/job/next-actions.ts +++ b/src/job/next-actions.ts @@ -136,3 +136,15 @@ export function nextActionsForRunMany(input: { }, ); } + +export function nextActionsForActionRun(input: { runId: string }): NextAction[] { + return renderNextActions( + [ + { + command: 'dispatch job readable --run-id {runId}', + description: 'full request/response trace', + }, + ], + { runId: input.runId }, + ); +} diff --git a/test/run-cli.test.ts b/test/run-cli.test.ts new file mode 100644 index 0000000..20f5283 --- /dev/null +++ b/test/run-cli.test.ts @@ -0,0 +1,256 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(THIS_DIR, '..'); +const SDK_IMPORT = JSON.stringify(pathToFileURL(path.join(REPO_ROOT, 'src', 'index.ts')).href); +const ZOD_IMPORT = JSON.stringify(pathToFileURL(path.join(REPO_ROOT, 'node_modules', 'zod', 'index.js')).href); +const RUN_FIXTURE_MODULE_NAME = `zz-run-fixture-${process.pid}`; +const RUN_FIXTURE_MODULE_DIR = path.join(REPO_ROOT, 'modules', RUN_FIXTURE_MODULE_NAME); + +function runCli(args: string[], env?: NodeJS.ProcessEnv) { + const out = spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', '--json', ...args], { + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 15000, + env: { + ...process.env, + ...env, + }, + }); + + const stdout = out.stdout.trim(); + return { + status: out.status, + stdout, + stderr: out.stderr, + json: stdout ? JSON.parse(stdout) : null, + }; +} + +function writeFixtureModule(contents: string): void { + fs.mkdirSync(RUN_FIXTURE_MODULE_DIR, { recursive: true }); + fs.writeFileSync( + path.join(RUN_FIXTURE_MODULE_DIR, 'module.json'), + `${JSON.stringify( + { + name: RUN_FIXTURE_MODULE_NAME, + version: '1.0.0', + entry: 'index.mjs', + }, + null, + 2, + )}\n`, + 'utf8', + ); + fs.writeFileSync(path.join(RUN_FIXTURE_MODULE_DIR, 'index.mjs'), contents, 'utf8'); +} + +afterEach(() => { + fs.rmSync(RUN_FIXTURE_MODULE_DIR, { recursive: true, force: true }); + for (const entry of fs.readdirSync(os.tmpdir())) { + if (entry.startsWith('dispatch-run-cli-test-')) { + fs.rmSync(path.join(os.tmpdir(), entry), { recursive: true, force: true }); + } + } +}); + +describe('dispatch run CLI', () => { + it('runs one action and writes compatible artifacts', () => { + writeFixtureModule( + [ + `import { defineAction, defineModule } from ${SDK_IMPORT};`, + `import { z } from ${ZOD_IMPORT};`, + 'export default defineModule({', + ` name: '${RUN_FIXTURE_MODULE_NAME}',`, + " version: '1.0.0',", + ' actions: {', + " ping: defineAction({", + " description: 'Run one action directly.',", + ' schema: z.object({ count: z.number().int(), enabled: z.boolean() }),', + " handler: async (ctx, payload) => { ctx.artifacts.appendActivity(`ping count=${payload.count}`); return { response: payload, detail: 'pong' }; },", + ' }),', + ' },', + '});', + ].join('\n'), + ); + + const result = runCli(['run', `${RUN_FIXTURE_MODULE_NAME}.ping`, '--input', 'count=3', '--input', 'enabled=true']); + + expect(result.status).toBe(0); + expect(result.json).toEqual( + expect.objectContaining({ + cliVersion: expect.any(String), + action: `${RUN_FIXTURE_MODULE_NAME}.ping`, + status: 'SUCCESS', + runId: expect.any(String), + runDir: expect.any(String), + response: { + count: 3, + enabled: true, + }, + detail: 'pong', + next: [ + { + command: expect.stringContaining('dispatch job readable --run-id'), + description: 'full request/response trace', + }, + ], + }), + ); + + const runDir = result.json?.runDir as string; + expect(JSON.parse(fs.readFileSync(path.join(runDir, 'meta.json'), 'utf8'))).toEqual( + expect.objectContaining({ + action: `${RUN_FIXTURE_MODULE_NAME}.ping`, + runId: result.json?.runId, + }), + ); + expect(JSON.parse(fs.readFileSync(path.join(runDir, 'summary.json'), 'utf8'))).toEqual( + expect.objectContaining({ + runId: result.json?.runId, + runDir, + jobType: `action-run:${RUN_FIXTURE_MODULE_NAME}.ping`, + status: 'SUCCESS', + }), + ); + expect(JSON.parse(fs.readFileSync(path.join(runDir, 'module_resolution.json'), 'utf8'))).toEqual( + expect.objectContaining({ + steps: [ + expect.objectContaining({ + stepAction: `${RUN_FIXTURE_MODULE_NAME}.ping`, + }), + ], + }), + ); + }); + + it('returns NOT_FOUND for an unknown action', () => { + const result = runCli(['run', 'missing.action']); + + expect(result.status).toBe(4); + expect(result.json).toEqual( + expect.objectContaining({ + status: 'error', + code: 'NOT_FOUND', + }), + ); + }); + + it('fails with USAGE_ERROR when required inputs are missing', () => { + writeFixtureModule( + [ + `import { defineAction, defineModule } from ${SDK_IMPORT};`, + `import { z } from ${ZOD_IMPORT};`, + 'export default defineModule({', + ` name: '${RUN_FIXTURE_MODULE_NAME}',`, + " version: '1.0.0',", + ' actions: {', + " ping: defineAction({", + " description: 'Run one action directly.',", + ' schema: z.object({ count: z.number().int() }),', + " handler: async (_ctx, payload) => ({ response: payload, detail: 'pong' }),", + ' }),', + ' },', + '});', + ].join('\n'), + ); + + const result = runCli(['run', `${RUN_FIXTURE_MODULE_NAME}.ping`]); + + expect(result.status).toBe(2); + expect(result.json).toEqual( + expect.objectContaining({ + status: 'error', + code: 'USAGE_ERROR', + message: 'action input preflight failed', + }), + ); + }); + + it('resolves env-backed credentials for standalone action runs', () => { + writeFixtureModule( + [ + `import { defineAction, defineModule } from ${SDK_IMPORT};`, + `import { z } from ${ZOD_IMPORT};`, + 'export default defineModule({', + ` name: '${RUN_FIXTURE_MODULE_NAME}',`, + " version: '1.0.0',", + ' actions: {', + " login: defineAction({", + " description: 'Log in directly.',", + ' schema: z.object({}),', + " credentialSchema: z.object({ token: z.string().min(1) }),", + " handler: async (ctx) => ({ response: { token: ctx.credential.token }, detail: 'logged in' }),", + ' }),', + ' },', + '});', + ].join('\n'), + ); + + const result = runCli( + ['run', `${RUN_FIXTURE_MODULE_NAME}.login`, '--credential', 'token=RUN_FIXTURE_TOKEN'], + { RUN_FIXTURE_TOKEN: 'demo-token' }, + ); + + expect(result.status).toBe(0); + expect(result.json).toEqual( + expect.objectContaining({ + status: 'SUCCESS', + response: { token: 'demo-token' }, + }), + ); + }); + + it('writes failed summary artifacts and returns next actions when the handler throws', () => { + writeFixtureModule( + [ + `import { defineAction, defineModule } from ${SDK_IMPORT};`, + `import { z } from ${ZOD_IMPORT};`, + 'export default defineModule({', + ` name: '${RUN_FIXTURE_MODULE_NAME}',`, + " version: '1.0.0',", + ' actions: {', + " fail: defineAction({", + " description: 'Fail on purpose.',", + ' schema: z.object({}),', + " handler: async () => { throw new Error('boom'); },", + ' }),', + ' },', + '});', + ].join('\n'), + ); + + const result = runCli(['run', `${RUN_FIXTURE_MODULE_NAME}.fail`]); + + expect(result.status).toBe(1); + expect(result.json).toEqual( + expect.objectContaining({ + status: 'error', + code: 'RUNTIME_ERROR', + message: 'boom', + details: expect.objectContaining({ + runId: expect.any(String), + runDir: expect.any(String), + }), + next: [ + { + command: expect.stringContaining('dispatch job readable --run-id'), + description: 'full request/response trace', + }, + ], + }), + ); + + const summary = JSON.parse(fs.readFileSync(path.join(result.json.details.runDir, 'summary.json'), 'utf8')); + expect(summary).toEqual( + expect.objectContaining({ + status: 'FAILED', + }), + ); + }); +}); diff --git a/test/schema-coerce.test.ts b/test/schema-coerce.test.ts new file mode 100644 index 0000000..04c1430 --- /dev/null +++ b/test/schema-coerce.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { coerceInputsFromSchema, resolveEffectiveType } from '../src/execution/schema-coerce.ts'; + +describe('schema coercion', () => { + it('coerces simple top-level scalar fields', () => { + const schema = z.object({ + name: z.string(), + count: z.number(), + enabled: z.boolean(), + }); + + const result = coerceInputsFromSchema( + { + name: 'demo', + count: '42', + enabled: 'true', + }, + schema, + ); + + expect(result.issues).toEqual([]); + expect(result.payload).toEqual({ + name: 'demo', + count: 42, + enabled: true, + }); + }); + + it('coerces enum, optional, array, and object fields', () => { + const schema = z.object({ + status: z.enum(['OPEN', 'CLOSED']), + note: z.string().optional(), + items: z.array(z.object({ id: z.number() })), + config: z.object({ retry: z.boolean() }), + }); + + const result = coerceInputsFromSchema( + { + status: 'OPEN', + note: 'hello', + items: '[{"id":1}]', + config: '{"retry":true}', + }, + schema, + ); + + expect(result.issues).toEqual([]); + expect(result.payload).toEqual({ + status: 'OPEN', + note: 'hello', + items: [{ id: 1 }], + config: { retry: true }, + }); + }); + + it('passes unknown keys through with JSON.parse fallback', () => { + const schema = z.object({ + known: z.string(), + }); + + const result = coerceInputsFromSchema( + { + known: 'value', + extra: '{"x":1}', + }, + schema, + ); + + expect(result.issues).toEqual([]); + expect(result.payload).toEqual({ + known: 'value', + extra: { x: 1 }, + }); + }); + + it('reports coercion failures without stopping at the first issue', () => { + const schema = z.object({ + count: z.number(), + enabled: z.boolean(), + config: z.object({ retry: z.boolean() }), + whole: z.number().int(), + }); + + const result = coerceInputsFromSchema( + { + count: 'abc', + enabled: 'yes', + config: 'nope', + whole: '1.5', + }, + schema, + ); + + expect(result.payload).toEqual({}); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'inputs.count' }), + expect.objectContaining({ path: 'inputs.enabled' }), + expect.objectContaining({ path: 'inputs.config' }), + expect.objectContaining({ path: 'inputs.whole' }), + ]), + ); + }); +}); + +describe('resolveEffectiveType', () => { + it('handles direct type nodes', () => { + expect(resolveEffectiveType({ type: 'string' })).toBe('string'); + }); + + it('infers enums', () => { + expect(resolveEffectiveType({ enum: ['A', 'B'] })).toBe('string'); + }); + + it('unwraps optional anyOf encodings when present', () => { + expect(resolveEffectiveType({ anyOf: [{ type: 'string' }, { not: {} }] })).toBe('string'); + }); + + it('returns unknown for ambiguous unions', () => { + expect(resolveEffectiveType({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toBe('unknown'); + }); + + it('returns unknown when no type information is available', () => { + expect(resolveEffectiveType({})).toBe('unknown'); + }); +});