Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
*.tsbuildinfo
.DS_Store
coverage/
.npmrc
.env*
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 9 additions & 4 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@ function formatZodError(error: ZodError): string {
function runGates(
gates: Gate<unknown>[],
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
Expand Down
12 changes: 11 additions & 1 deletion src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,23 @@ import type { Step, StepConfig } from './types.js'
export function defineStep<TInput, TOutput>(
config: StepConfig<TInput, TOutput>,
): Step<TInput, TOutput> {
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<TInput>,
outputSchema: config.output as z.ZodType<TOutput>,
gates: config.gates ?? [],
retry: {
maxAttempts: config.retry?.maxAttempts ?? 1,
maxAttempts,
on: config.retry?.on ?? ['schema', 'gate'],
},
run: config.run,
Expand Down
23 changes: 23 additions & 0 deletions tests/gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
})
47 changes: 47 additions & 0 deletions tests/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
Loading