diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..272872c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Require review from @govindkavaturi-art for critical paths +src/* @govindkavaturi-art +.github/* @govindkavaturi-art +package.json @govindkavaturi-art +tsconfig.json @govindkavaturi-art diff --git a/.gitignore b/.gitignore index 02191d9..ada87a3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ *.tsbuildinfo .DS_Store coverage/ +.npmrc +.env* diff --git a/README.md b/README.md index 76f12da..7acacf7 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,33 @@ const desc = myPipeline.describe() Schemas are serialized as JSON Schema via `zod-to-json-schema`. +## Handling Failures Safely + +When a step fails, the `Failure` object contains the raw `input`, `output`, and `error.message` from the step. This is useful for debugging but may contain sensitive data if your step processes PII, API keys, or other confidential information. + +**Before exposing failures to end users, logs, or monitoring systems, sanitize the failure object:** + +```typescript +const result = await myPipeline.run(input) + +if (!result.ok) { + // Internal logging — full context + logger.debug('Pipeline failure', result.failure) + + // User-facing — redact raw data + const safeError = { + step: result.failure.step, + reason: result.failure.reason, + type: result.failure.type, + attempts: result.failure.attempts, + // Omit: input, output (may contain sensitive data) + } + return { error: safeError } +} +``` + +A pipeline-level `onFailure` redaction hook is planned for v0.2. + ## Cuechain + CueAPI Cuechain verifies contracts between steps. [CueAPI](https://cueapi.ai) verifies outcomes against reality. Use one, use both. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..00ef810 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,5 @@ +# Roadmap + +## v0.2 + +- **`onFailure` redaction hook**: Pipeline-level callback to sanitize `Failure` objects before they leave the pipeline boundary. Allows callers to strip PII, API keys, or other sensitive data from `input`, `output`, and `reason` fields without manual post-processing. diff --git a/src/runner.ts b/src/runner.ts index aab2998..ba3fcd7 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -20,11 +20,16 @@ function formatZodError(error: ZodError): string { function runGates( gates: Gate[], output: unknown, -): { reason: string; context?: unknown } | null { +): { reason: string; context?: unknown; thrown?: boolean } | null { for (const gate of gates) { - const result = gate(output) - if (!result.ok) { - return { reason: result.reason, context: result.context } + try { + const result = gate(output) + if (!result.ok) { + return { reason: result.reason, context: result.context } + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return { reason: `Gate threw: ${reason}`, thrown: true } } } return null diff --git a/src/step.ts b/src/step.ts index 3de619f..f0173ae 100644 --- a/src/step.ts +++ b/src/step.ts @@ -26,13 +26,23 @@ import type { Step, StepConfig } from './types.js' export function defineStep( config: StepConfig, ): Step { + const maxAttempts = config.retry?.maxAttempts ?? 1 + + if (maxAttempts < 1 || maxAttempts > 20) { + throw new RangeError(`maxAttempts must be between 1 and 20 (got ${maxAttempts})`) + } + + if (!Number.isInteger(maxAttempts)) { + throw new RangeError(`maxAttempts must be an integer (got ${maxAttempts})`) + } + return { name: config.name, inputSchema: config.input as z.ZodType, outputSchema: config.output as z.ZodType, gates: config.gates ?? [], retry: { - maxAttempts: config.retry?.maxAttempts ?? 1, + maxAttempts, on: config.retry?.on ?? ['schema', 'gate'], }, run: config.run, diff --git a/tests/gates.test.ts b/tests/gates.test.ts index 75d600c..8586c9d 100644 --- a/tests/gates.test.ts +++ b/tests/gates.test.ts @@ -86,4 +86,27 @@ describe('quality gates', () => { expect(result.failure.reason).toContain('expected 3 items, got 2') } }) + + it('attributes thrown gate exceptions as type gate', async () => { + const step = defineStep({ + name: 'throwing-gate', + input: z.object({ x: z.number() }), + output: z.object({ y: z.number() }), + gates: [ + () => { + throw new Error('gate exploded') + }, + ], + run: async (input) => ({ y: input.x * 2 }), + }) + + const p = pipeline('throw-gate').step(step) + const result = await p.run({ x: 5 }) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.failure.type).toBe('gate') + expect(result.failure.reason).toContain('Gate threw: gate exploded') + } + }) }) diff --git a/tests/retry.test.ts b/tests/retry.test.ts index ddacd27..82829f1 100644 --- a/tests/retry.test.ts +++ b/tests/retry.test.ts @@ -165,4 +165,51 @@ describe('retry with failure context', () => { expect(result.ok).toBe(false) expect(attempts).toBe(1) }) + + it('throws RangeError when maxAttempts is 0', () => { + expect(() => + defineStep({ + name: 'bad-retry', + input: z.object({ x: z.number() }), + output: z.object({ y: z.number() }), + retry: { maxAttempts: 0 }, + run: async (input) => ({ y: input.x }), + }), + ).toThrow(RangeError) + }) + + it('throws RangeError when maxAttempts exceeds 20', () => { + expect(() => + defineStep({ + name: 'bad-retry', + input: z.object({ x: z.number() }), + output: z.object({ y: z.number() }), + retry: { maxAttempts: 21 }, + run: async (input) => ({ y: input.x }), + }), + ).toThrow(RangeError) + }) + + it('throws RangeError when maxAttempts is not an integer', () => { + expect(() => + defineStep({ + name: 'bad-retry', + input: z.object({ x: z.number() }), + output: z.object({ y: z.number() }), + retry: { maxAttempts: 2.5 }, + run: async (input) => ({ y: input.x }), + }), + ).toThrow(RangeError) + }) + + it('accepts maxAttempts of 20 (upper bound)', async () => { + const step = defineStep({ + name: 'max-retry', + input: z.object({ x: z.number() }), + output: z.object({ y: z.number() }), + retry: { maxAttempts: 20 }, + run: async (input) => ({ y: input.x }), + }) + expect(step.retry.maxAttempts).toBe(20) + }) })