diff --git a/README.md b/README.md index f557cb1..992bfeb 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,6 @@ pnpm build # Compile to dist/ | `run_graph_workflow` | Execute graph-based workflows | | `query_trace` | Query execution traces | -## Live integration mode - -```bash -NEXUS_LIVE=true npx tsx src/run-live.ts -``` - ## License MIT diff --git a/package.json b/package.json index f9cb3d5..8f2d9d3 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,10 @@ "scripts": { "build": "tsc", "test": "vitest run", - "typecheck": "tsc --noEmit", - "live": "NEXUS_LIVE=true tsx src/run-live.ts" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/node": "^22.19.21", - "tsx": "^4.19.0", "typescript": "^5.9.0", "vitest": "^3.2.6", "zod": "^3.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ba1640..20f336a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@types/node': specifier: ^22.19.21 version: 22.19.21 - tsx: - specifier: ^4.19.0 - version: 4.21.0 typescript: specifier: ^5.9.0 version: 5.9.3 @@ -882,6 +879,7 @@ snapshots: get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 + optional: true js-tokens@9.0.1: {} @@ -909,7 +907,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - resolve-pkg-maps@1.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true rollup@4.62.0: dependencies: @@ -975,6 +974,7 @@ snapshots: get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 + optional: true typescript@5.9.3: {} diff --git a/src/fixtures/mock-responses.ts b/src/fixtures/mock-responses.ts index 81086b7..f94d68e 100644 --- a/src/fixtures/mock-responses.ts +++ b/src/fixtures/mock-responses.ts @@ -7,8 +7,6 @@ import type { RunGraphResponse, GraphWorkflowInfo, QueryTraceResponse, - RunWorkflowResponse, - RunWorkflowDryRun, } from '../types.js'; // ============================================================================ @@ -205,52 +203,3 @@ export const MOCK_TRACE_NOT_FOUND: QueryTraceResponse = { truncated: false, source: 'not_found', }; - -// ============================================================================ -// run_workflow -// ============================================================================ - -export const MOCK_RUN_WORKFLOW_SUCCESS: RunWorkflowResponse = { - executionId: 'exec-2026-04-17-0001', - workflowName: 'code-review', - status: 'completed', - stepResults: [ - { stepId: 'analyze', status: 'success', durationMs: 1250 }, - { stepId: 'report', status: 'success', durationMs: 340 }, - ], - output: { summary: 'Review completed: 3 findings.' }, - durationMs: 1600, -}; - -export const MOCK_RUN_WORKFLOW_FAILED: RunWorkflowResponse = { - executionId: 'exec-2026-04-17-0002', - workflowName: 'bug-fix', - status: 'failed', - stepResults: [ - { stepId: 'reproduce', status: 'success', durationMs: 100 }, - { stepId: 'patch', status: 'failed', durationMs: 50, error: 'Adapter unavailable' }, - { stepId: 'verify', status: 'skipped', durationMs: 0 }, - ], - output: null, - durationMs: 155, -}; - -export const MOCK_RUN_WORKFLOW_DRY_RUN: RunWorkflowDryRun = { - valid: true, - workflowName: 'documentation-update', - stepCount: 4, - inputsProvided: ['file', 'section'], - inputsRequired: ['file', 'section'], - inputsMissing: [], - validationErrors: [], -}; - -export const MOCK_RUN_WORKFLOW_DRY_RUN_INVALID: RunWorkflowDryRun = { - valid: false, - workflowName: 'documentation-update', - stepCount: 4, - inputsProvided: ['file'], - inputsRequired: ['file', 'section'], - inputsMissing: ['section'], - validationErrors: ["Missing required input 'section'"], -}; diff --git a/src/index.ts b/src/index.ts index 139e11e..95ab876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { toErrorResult, countResults, runWorkflowPipeline, + ToolCallTimeoutError, } from './runner-pipeline.js'; export { generateReport } from './reporter.js'; export type { ReportFormat } from './reporter.js'; @@ -30,10 +31,6 @@ export type { WorkflowRunResult, RunnerReport, RunnerConfig, - RunWorkflowInput, - RunWorkflowResponse, - RunWorkflowDryRun, - StepResultSummary, } from './types.js'; export { ListWorkflowsInputSchema, @@ -43,7 +40,4 @@ export { GraphWorkflowListSchema, QueryTraceInputSchema, QueryTraceResponseSchema, - RunWorkflowInputSchema, - RunWorkflowResponseSchema, - RunWorkflowDryRunSchema, } from './types.js'; diff --git a/src/live-caller.ts b/src/live-caller.ts deleted file mode 100644 index 234f595..0000000 --- a/src/live-caller.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Live MCP tool caller — bridges ToolCaller interface to a real nexus-agents MCP server. - */ - -import type { ToolCaller } from './runner-pipeline.js'; - -export function createLiveCaller( - callFn: (tool: string, args: Record) => Promise, -): ToolCaller { - return { call: callFn }; -} - -export function isLiveMode(): boolean { - return process.env['NEXUS_LIVE'] === 'true'; -} diff --git a/src/run-live.ts b/src/run-live.ts deleted file mode 100644 index d68f9e0..0000000 --- a/src/run-live.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env tsx -/** - * Run the workflow runner against a live nexus-agents MCP server. - * - * Usage: NEXUS_LIVE=true npx tsx src/run-live.ts - */ - -import { runWorkflowPipeline } from './runner-pipeline.js'; -import { isLiveMode } from './live-caller.js'; -import type { ToolCaller } from './runner-pipeline.js'; - -async function main(): Promise { - if (!isLiveMode()) { - console.error('Set NEXUS_LIVE=true to run against a live MCP server.'); - process.exit(1); - } - - let caller: ToolCaller; - try { - const bridgePath = './live-bridge.js'; - const mod: Record = await import(bridgePath); - const factory = mod['createMcpCaller'] as (() => Promise) | undefined; - if (typeof factory !== 'function') throw new Error('live-bridge.ts must export createMcpCaller()'); - caller = await factory(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error(`Failed to load live bridge: ${msg}`); - process.exit(1); - } - - console.log('Running workflow pipeline against live MCP server...\n'); - const result = await runWorkflowPipeline(caller); - console.log(JSON.stringify(result, null, 2)); - - process.exit(0); -} - -void main(); diff --git a/src/runner-pipeline.test.ts b/src/runner-pipeline.test.ts index e301f4b..0c38ea6 100644 --- a/src/runner-pipeline.test.ts +++ b/src/runner-pipeline.test.ts @@ -13,6 +13,7 @@ import { toErrorResult, countResults, runWorkflowPipeline, + ToolCallTimeoutError, } from './runner-pipeline.js'; import type { RunnerConfig, WorkflowRunResult } from './types.js'; import { @@ -347,4 +348,158 @@ describe('runWorkflowPipeline', () => { expect(result.graphResults[0]!.status).toBe('error'); expect(result.failed).toBe(1); }); + + // --- #15: preserve the real failure cause instead of "Execution failed" --- + + it('preserves the real error message on a failed graph execution', async () => { + let graphIdx = 0; + const caller: ToolCaller = { + call: vi.fn(async (toolName: string) => { + if (toolName === 'list_workflows') return MOCK_LIST_WORKFLOWS; + if (toolName === 'run_graph_workflow') { + if (graphIdx++ === 0) return MOCK_GRAPH_LIST.slice(0, 1); + throw new Error('ECONNREFUSED 127.0.0.1:9000'); + } + throw new Error(`Unexpected: ${toolName}`); + }), + }; + + const result = await runWorkflowPipeline(caller); + + expect(result.graphResults[0]!.error).toBe('ECONNREFUSED 127.0.0.1:9000'); + expect(result.graphResults[0]!.error).not.toBe('Execution failed'); + }); + + it('preserves a Zod schema-mismatch error from a bad server response', async () => { + let graphIdx = 0; + const caller: ToolCaller = { + call: vi.fn(async (toolName: string) => { + if (toolName === 'list_workflows') return MOCK_LIST_WORKFLOWS; + if (toolName === 'run_graph_workflow') { + if (graphIdx++ === 0) return MOCK_GRAPH_LIST.slice(0, 1); + // Unexpected shape — executeGraph's Zod parse should reject this. + return { workflow: 'echo', status: 'unknown-status' }; + } + throw new Error(`Unexpected: ${toolName}`); + }), + }; + + const result = await runWorkflowPipeline(caller); + + expect(result.graphResults[0]!.status).toBe('error'); + // The Zod failure carries actionable detail, not a generic string. + expect(result.graphResults[0]!.error).not.toBe('Execution failed'); + expect(result.graphResults[0]!.error!.length).toBeGreaterThan(0); + }); + + it('distinguishes a failed trace query from "trace not requested"', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + let graphIdx = 0; + const caller: ToolCaller = { + call: vi.fn(async (toolName: string) => { + if (toolName === 'list_workflows') return MOCK_LIST_WORKFLOWS; + if (toolName === 'run_graph_workflow') { + return graphIdx++ === 0 ? MOCK_GRAPH_LIST.slice(0, 1) : MOCK_ECHO_RESULT; + } + if (toolName === 'query_trace') throw new Error('trace store offline'); + throw new Error(`Unexpected: ${toolName}`); + }), + }; + + const result = await runWorkflowPipeline(caller, { traceRunId: 'wf-run-001' }); + + expect(result.traceResult).toBeNull(); + expect(result.traceError).toBe('trace store offline'); + } finally { + errSpy.mockRestore(); + } + }); + + it('leaves traceError undefined when no trace is requested', async () => { + let graphIdx = 0; + const caller: ToolCaller = { + call: vi.fn(async (toolName: string) => { + if (toolName === 'list_workflows') return MOCK_LIST_WORKFLOWS; + if (toolName === 'run_graph_workflow') { + return graphIdx++ === 0 ? MOCK_GRAPH_LIST.slice(0, 1) : MOCK_ECHO_RESULT; + } + throw new Error(`Unexpected: ${toolName}`); + }), + }; + + const result = await runWorkflowPipeline(caller); + + expect(result.traceResult).toBeNull(); + expect(result.traceError).toBeUndefined(); + }); +}); + +// ============================================================================ +// #16: per-call timeout / abort +// ============================================================================ + +describe('timeout enforcement', () => { + it('aborts a hung graph execution and reports it without hanging', async () => { + let graphIdx = 0; + const caller: ToolCaller = { + call: vi.fn((toolName: string, args: Record) => { + if (toolName === 'list_workflows') return Promise.resolve(MOCK_LIST_WORKFLOWS); + if (toolName === 'run_graph_workflow') { + if (graphIdx++ === 0) return Promise.resolve(MOCK_GRAPH_LIST.slice(0, 1)); + // Simulate a hung server: never resolves on its own, but honors abort. + return new Promise((_, reject) => { + const signal = args['signal'] as AbortSignal | undefined; + signal?.addEventListener('abort', () => + reject(new Error('aborted by signal')) + ); + }); + } + return Promise.reject(new Error(`Unexpected: ${toolName}`)); + }), + }; + + const result = await runWorkflowPipeline(caller, { timeoutMs: 20 }); + + expect(result.graphResults.length).toBe(1); + expect(result.graphResults[0]!.status).toBe('error'); + expect(result.graphResults[0]!.error).toMatch(/timed out after 20ms/); + expect(result.failed).toBe(1); + }); + + it('passes an AbortSignal through to the caller', async () => { + const caller: ToolCaller = { + call: vi.fn(async () => MOCK_LIST_WORKFLOWS), + }; + + await listTemplates(caller, 1000); + + const callMock = caller.call as ReturnType; + const passedArgs = callMock.mock.calls[0]![1] as Record; + expect(passedArgs['signal']).toBeInstanceOf(AbortSignal); + }); + + it('does not attach a signal or deadline when timeoutMs is omitted', async () => { + const caller: ToolCaller = { + call: vi.fn(async () => MOCK_LIST_WORKFLOWS), + }; + + await listTemplates(caller); + + const callMock = caller.call as ReturnType; + const passedArgs = callMock.mock.calls[0]![1] as Record; + expect(passedArgs['signal']).toBeUndefined(); + }); + + it('throws ToolCallTimeoutError from a step when the call exceeds the budget', async () => { + const caller: ToolCaller = { + call: vi.fn( + () => new Promise(() => {}) // never resolves + ), + }; + + await expect(listTemplates(caller, 15)).rejects.toBeInstanceOf( + ToolCallTimeoutError + ); + }); }); diff --git a/src/runner-pipeline.ts b/src/runner-pipeline.ts index ddfed35..cf4c1ba 100644 --- a/src/runner-pipeline.ts +++ b/src/runner-pipeline.ts @@ -29,6 +29,58 @@ export interface ToolCaller { call(toolName: string, args: Record): Promise; } +// ============================================================================ +// Timeout enforcement +// ============================================================================ + +/** Error thrown when an MCP call exceeds the configured per-call timeout. */ +export class ToolCallTimeoutError extends Error { + constructor( + readonly toolName: string, + readonly timeoutMs: number + ) { + super(`Tool call '${toolName}' timed out after ${timeoutMs}ms`); + this.name = 'ToolCallTimeoutError'; + } +} + +/** + * Invoke a tool call, racing it against a timeout. If `timeoutMs` is undefined + * or <= 0 the call is awaited without a deadline. On timeout the caller is + * signalled via an AbortController (best-effort — callers that ignore the + * signal still lose the race) and a ToolCallTimeoutError is thrown. + */ +async function callWithTimeout( + caller: ToolCaller, + toolName: string, + args: Record, + timeoutMs: number | undefined +): Promise { + if (timeoutMs === undefined || timeoutMs <= 0) { + return caller.call(toolName, args); + } + + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + // Reject with the timeout error first so it wins the race, then signal + // the caller to abort its in-flight work (best-effort cleanup). + reject(new ToolCallTimeoutError(toolName, timeoutMs)); + controller.abort(); + }, timeoutMs); + }); + + try { + return await Promise.race([ + caller.call(toolName, { ...args, signal: controller.signal }), + timeout, + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + // ============================================================================ // Default graph workflow inputs // ============================================================================ @@ -49,17 +101,29 @@ const DEFAULT_GRAPH_INPUTS: Readonly>> = /** Step 1: List all workflow templates. */ export async function listTemplates( - caller: ToolCaller + caller: ToolCaller, + timeoutMs?: number ): Promise { - const raw = await caller.call('list_workflows', { format: 'names' }); + const raw = await callWithTimeout( + caller, + 'list_workflows', + { format: 'names' }, + timeoutMs + ); return ListWorkflowsResponseSchema.parse(raw); } /** Step 2: List all graph workflows. */ export async function listGraphWorkflows( - caller: ToolCaller + caller: ToolCaller, + timeoutMs?: number ): Promise { - const raw = await caller.call('run_graph_workflow', { workflow: 'list' }); + const raw = await callWithTimeout( + caller, + 'run_graph_workflow', + { workflow: 'list' }, + timeoutMs + ); return GraphWorkflowListSchema.parse(raw); } @@ -67,22 +131,34 @@ export async function listGraphWorkflows( export async function executeGraph( caller: ToolCaller, name: string, - inputs: Record + inputs: Record, + timeoutMs?: number ): Promise { - const raw = await caller.call('run_graph_workflow', { - workflow: name, - inputs, - enableCheckpointing: true, - }); + const raw = await callWithTimeout( + caller, + 'run_graph_workflow', + { + workflow: name, + inputs, + enableCheckpointing: true, + }, + timeoutMs + ); return RunGraphResponseSchema.parse(raw); } /** Step 4: Query traces for a run. */ export async function queryTrace( caller: ToolCaller, - runId: string + runId: string, + timeoutMs?: number ): Promise { - const raw = await caller.call('query_trace', { runId }); + const raw = await callWithTimeout( + caller, + 'query_trace', + { runId }, + timeoutMs + ); return QueryTraceResponseSchema.parse(raw); } @@ -143,11 +219,13 @@ export async function runWorkflowPipeline( caller: ToolCaller, config: RunnerConfig = {} ): Promise { + const { timeoutMs } = config; + // Step 1: Discover templates - const templates = await listTemplates(caller); + const templates = await listTemplates(caller, timeoutMs); // Step 2: Discover and execute graph workflows - const graphInfos = await listGraphWorkflows(caller); + const graphInfos = await listGraphWorkflows(caller, timeoutMs); const graphResults: WorkflowRunResult[] = []; if (config.runGraphWorkflows !== false) { @@ -156,21 +234,31 @@ export async function runWorkflowPipeline( const inputs = userInputs[info.name] ?? DEFAULT_GRAPH_INPUTS[info.name] ?? {}; try { - const response = await executeGraph(caller, info.name, inputs); + const response = await executeGraph(caller, info.name, inputs, timeoutMs); graphResults.push(toRunResult(info, response)); - } catch { - graphResults.push(toErrorResult(info.name, 'Execution failed')); + } catch (e) { + // Preserve the real failure (transport error, Zod schema mismatch, + // timeout) — collapsing every cause to "Execution failed" discards the + // single most useful diagnostic signal this exerciser produces. + graphResults.push(toErrorResult(info.name, errorMessage(e))); } } } // Step 3: Query traces (if configured) let traceResult: QueryTraceResponse | null = null; + let traceError: string | undefined; if (config.traceRunId !== undefined) { try { - traceResult = await queryTrace(caller, config.traceRunId); - } catch { + traceResult = await queryTrace(caller, config.traceRunId, timeoutMs); + } catch (e) { + // A failed trace query is distinct from "trace never requested"; carry + // the error so the report can tell them apart, and surface it on stderr. + traceError = errorMessage(e); traceResult = null; + console.error( + `query_trace failed for runId '${config.traceRunId}': ${traceError}` + ); } } @@ -183,5 +271,11 @@ export async function runWorkflowPipeline( passed, failed, traceResult, + ...(traceError !== undefined ? { traceError } : {}), }; } + +/** Normalize an unknown thrown value to a message string. */ +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/src/types.test.ts b/src/types.test.ts index 663f42c..34ce910 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -11,9 +11,6 @@ import { GraphWorkflowListSchema, QueryTraceInputSchema, QueryTraceResponseSchema, - RunWorkflowInputSchema, - RunWorkflowResponseSchema, - RunWorkflowDryRunSchema, } from './types.js'; import { MOCK_LIST_WORKFLOWS, @@ -24,10 +21,6 @@ import { MOCK_FAILED_RESULT, MOCK_TRACE_RESPONSE, MOCK_TRACE_NOT_FOUND, - MOCK_RUN_WORKFLOW_SUCCESS, - MOCK_RUN_WORKFLOW_FAILED, - MOCK_RUN_WORKFLOW_DRY_RUN, - MOCK_RUN_WORKFLOW_DRY_RUN_INVALID, } from './fixtures/mock-responses.js'; describe('ListWorkflowsInputSchema', () => { @@ -187,159 +180,3 @@ describe('QueryTraceResponseSchema', () => { ).toBe(false); }); }); - -// ============================================================================ -// run_workflow -// ============================================================================ - -describe('RunWorkflowInputSchema', () => { - it('accepts valid input', () => { - const r = RunWorkflowInputSchema.safeParse({ - template: 'code-review', - inputs: { file: 'src/index.ts' }, - }); - expect(r.success).toBe(true); - }); - - it('accepts dryRun option', () => { - const r = RunWorkflowInputSchema.safeParse({ - template: 'documentation-update', - inputs: { section: 'overview' }, - dryRun: true, - }); - expect(r.success).toBe(true); - }); - - it('accepts empty inputs object', () => { - const r = RunWorkflowInputSchema.safeParse({ - template: 'code-review', - inputs: {}, - }); - expect(r.success).toBe(true); - }); - - it('accepts file-path template', () => { - const r = RunWorkflowInputSchema.safeParse({ - template: './workflows/custom.yaml', - inputs: {}, - }); - expect(r.success).toBe(true); - }); - - it('rejects empty template', () => { - expect( - RunWorkflowInputSchema.safeParse({ template: '', inputs: {} }).success - ).toBe(false); - }); - - it('rejects missing inputs', () => { - expect( - RunWorkflowInputSchema.safeParse({ template: 'code-review' }).success - ).toBe(false); - }); - - it('rejects non-string template', () => { - expect( - RunWorkflowInputSchema.safeParse({ template: 42, inputs: {} }).success - ).toBe(false); - }); - - it('rejects input key over 100 chars', () => { - const longKey = 'a'.repeat(101); - expect( - RunWorkflowInputSchema.safeParse({ - template: 'code-review', - inputs: { [longKey]: 'x' }, - }).success - ).toBe(false); - }); - - it('accepts unknown value types in inputs', () => { - const r = RunWorkflowInputSchema.safeParse({ - template: 'code-review', - inputs: { - str: 'hello', - num: 42, - bool: true, - obj: { nested: 'value' }, - arr: [1, 2, 3], - }, - }); - expect(r.success).toBe(true); - }); -}); - -describe('RunWorkflowResponseSchema', () => { - it('parses success response', () => { - const r = RunWorkflowResponseSchema.safeParse(MOCK_RUN_WORKFLOW_SUCCESS); - expect(r.success).toBe(true); - if (r.success) { - expect(r.data.status).toBe('completed'); - expect(r.data.stepResults).toHaveLength(2); - expect(r.data.stepResults[0]?.status).toBe('success'); - } - }); - - it('parses failed response with error on step', () => { - const r = RunWorkflowResponseSchema.safeParse(MOCK_RUN_WORKFLOW_FAILED); - expect(r.success).toBe(true); - if (r.success) { - expect(r.data.status).toBe('failed'); - const failedStep = r.data.stepResults.find((s) => s.status === 'failed'); - expect(failedStep).toBeDefined(); - expect(failedStep?.error).toBe('Adapter unavailable'); - } - }); - - it('accepts skipped step status', () => { - const r = RunWorkflowResponseSchema.safeParse(MOCK_RUN_WORKFLOW_FAILED); - expect(r.success).toBe(true); - if (r.success) { - expect(r.data.stepResults.some((s) => s.status === 'skipped')).toBe(true); - } - }); - - it('rejects invalid status', () => { - expect( - RunWorkflowResponseSchema.safeParse({ - ...MOCK_RUN_WORKFLOW_SUCCESS, - status: 'pending', - }).success - ).toBe(false); - }); - - it('rejects invalid stepResult status', () => { - const bad = { - ...MOCK_RUN_WORKFLOW_SUCCESS, - stepResults: [ - { stepId: 'x', status: 'bogus', durationMs: 1 }, - ], - }; - expect(RunWorkflowResponseSchema.safeParse(bad).success).toBe(false); - }); - - it('accepts null output', () => { - expect(RunWorkflowResponseSchema.safeParse(MOCK_RUN_WORKFLOW_FAILED).success).toBe(true); - }); -}); - -describe('RunWorkflowDryRunSchema', () => { - it('parses valid dry run', () => { - const r = RunWorkflowDryRunSchema.safeParse(MOCK_RUN_WORKFLOW_DRY_RUN); - expect(r.success).toBe(true); - if (r.success) { - expect(r.data.valid).toBe(true); - expect(r.data.inputsMissing).toHaveLength(0); - } - }); - - it('parses invalid dry run with missing inputs', () => { - const r = RunWorkflowDryRunSchema.safeParse(MOCK_RUN_WORKFLOW_DRY_RUN_INVALID); - expect(r.success).toBe(true); - if (r.success) { - expect(r.data.valid).toBe(false); - expect(r.data.inputsMissing).toContain('section'); - expect(r.data.validationErrors.length).toBeGreaterThan(0); - } - }); -}); diff --git a/src/types.ts b/src/types.ts index 7602e91..9461058 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,50 +107,6 @@ export const QueryTraceResponseSchema = z.object({ export type QueryTraceResponse = z.infer; -// ============================================================================ -// run_workflow -// ============================================================================ - -export const RunWorkflowInputSchema = z.object({ - template: z.string().min(1), - inputs: z.record(z.string().max(100), z.unknown()), - dryRun: z.boolean().optional(), -}); - -export type RunWorkflowInput = z.infer; - -const StepResultSummarySchema = z.object({ - stepId: z.string(), - status: z.enum(['success', 'failed', 'skipped']), - durationMs: z.number(), - error: z.string().optional(), -}); - -export type StepResultSummary = z.infer; - -export const RunWorkflowResponseSchema = z.object({ - executionId: z.string(), - workflowName: z.string(), - status: z.enum(['completed', 'failed']), - stepResults: z.array(StepResultSummarySchema), - output: z.unknown(), - durationMs: z.number(), -}); - -export type RunWorkflowResponse = z.infer; - -export const RunWorkflowDryRunSchema = z.object({ - valid: z.boolean(), - workflowName: z.string(), - stepCount: z.number(), - inputsProvided: z.array(z.string()), - inputsRequired: z.array(z.string()), - inputsMissing: z.array(z.string()), - validationErrors: z.array(z.string()), -}); - -export type RunWorkflowDryRun = z.infer; - // ============================================================================ // Runner types // ============================================================================ @@ -176,6 +132,13 @@ export interface RunnerReport { readonly passed: number; readonly failed: number; readonly traceResult: QueryTraceResponse | null; + /** + * Error message if a trace was requested (`config.traceRunId` set) but the + * `query_trace` call failed. Distinguishes "trace errored" from "trace not + * requested" (both leave `traceResult` null). Undefined when no trace was + * requested or the trace succeeded. + */ + readonly traceError?: string; } /** Runner configuration. */ @@ -183,4 +146,11 @@ export interface RunnerConfig { readonly runGraphWorkflows?: boolean; readonly traceRunId?: string; readonly graphInputs?: Readonly>>; + /** + * Per-call timeout in milliseconds applied to every MCP tool call. When a + * call exceeds this budget it is aborted and surfaced as an error result for + * that step, so a hung server cannot hang the whole run. Omit or set <= 0 to + * disable the timeout. + */ + readonly timeoutMs?: number; }