Skip to content

refactor(engine): tighten types & validation, remove cast tech debt#150

Merged
willwashburn merged 3 commits into
mainfrom
chore/engine-strict-types-validation
May 31, 2026
Merged

refactor(engine): tighten types & validation, remove cast tech debt#150
willwashburn merged 3 commits into
mainfrom
chore/engine-strict-types-validation

Conversation

@willwashburn

Copy link
Copy Markdown
Member

Summary

A review pass over packages/engine focused on type-safety tech debt — any/as casts — and input validation. The codebase was already disciplined (every body-reading route uses zod safeParse; no : any/as any), so this is mostly about removing duplicated unsafe casts and tightening the few real parse boundaries.

Net −280 LOC. Casts: 250 → ~50 (all remaining are framework status casts or guarded unknown narrowing). Zero any types left in the package.

Changes

Error handling — one helper instead of 93 copies

  • New src/lib/httpError.ts: errorResponse(c, err), asCodedError(err), codedError(msg, code, status). These narrow thrown values with a real instanceof Error check instead of the unchecked err as Error & { code?: string; status?: number } assertion that was copy-pasted into 93 catch blocks across 23 files.
  • Those catch blocks collapse to return errorResponse(c, err);. Blocks that also log/emit telemetry (workspace stream config, global onError, a2a JSON-RPC envelopes) keep their logic but use asCodedError.
  • Coded-error throw sites (action.ts, inboundWebhook.ts) now use the cast-free codedError() / Object.assign form.

Schema types flow through

  • Added .$type<Record<string, unknown>>() to the metadata/capabilities JSON columns in db/schema.ts, so ~30 row.metadata as Record<string, unknown> casts become clean ?? {}.
  • .default('{}').default({}) serializes to the identical DEFAULT '{}' SQL (same pattern inputSchema/outputSchema already used) — no migration change.

Validation at parse boundaries (zod)

  • adapters/node/files.ts: the HMAC-verified download-token payload is now safeParse'd — a malformed exp can no longer yield a non-expiring token.
  • engine/certify.ts: the three probe tests that walked external A2A responses via chained as Record<string, unknown> casts now use focused zod schemas, preserving the original lenient semantics.

EngineDb is generic, future-proofed per adapter

  • EngineDb<TRunResult = unknown>: engine core stays driver-agnostic (EngineDb<unknown>, never calls .run()), while the Node adapter specializes EngineDb<RunResult> (better-sqlite3) so run-result types flow through there without coupling core to a platform. A future Cloudflare adapter would declare EngineDb<D1Result> in its own package. Removes the last real any and its eslint-disable.

Deliberately left as-is

  • idempotency.ts JSON.parse as T (×3) — the app's own cache, generic T, no fixed schema to validate against.
  • a2a.ts param-walk casts and directory.ts skill/capability casts — narrowing unknown after typeof === 'object' guards on request/derived data, not DB rows.
  • 12 as ContentfulStatusCode — genuine success-path Hono status casts, plus the one centralized in the helper.

Verification

  • npm run local:parity — passes (103 endpoints)
  • npx turbo lint --force — 14/14
  • npx turbo test --force — 19/19 (DO_NOT_TRACK=1)
  • npx turbo build — all packages

🤖 Generated with Claude Code

Engine review pass focused on `any`/`as` casts and input validation.
Net -280 LOC; casts down 250 -> ~50 (all remaining are framework
status casts or guarded `unknown` narrowing).

Error handling
- Add src/lib/httpError.ts: errorResponse(c, err), asCodedError(err),
  codedError(msg, code, status). Narrows thrown values via `instanceof`
  instead of the unchecked `err as Error & {...}` assertion that was
  copy-pasted into 93 catch blocks across 23 files.
- Replace those catch blocks with errorResponse(c, err); blocks that also
  log/emit telemetry keep their logic but use asCodedError.
- Convert coded-error throw sites (action.ts, inboundWebhook.ts) to the
  cast-free codedError()/Object.assign form.

Schema typing flows through
- Add .$type<Record<string, unknown>>() to the metadata/capabilities JSON
  columns. Lets ~30 `row.metadata as Record<string, unknown>` casts become
  clean `?? {}`. `.default('{}')` -> `.default({})` serializes to the same
  DEFAULT '{}' SQL (no migration change).

Validation at parse boundaries (zod)
- files.ts: HMAC-verified download-token payload now safeParse'd, so a
  malformed exp can't yield a non-expiring token.
- certify.ts: the three probe tests that walked external A2A responses via
  chained `as Record<string, unknown>` casts now use focused zod schemas.

EngineDb is generic, future-proofed per adapter
- EngineDb<TRunResult = unknown>: core stays driver-agnostic; Node adapter
  specializes EngineDb<RunResult> (better-sqlite3), so run-result types flow
  through on that side without coupling core to a platform. Removes the last
  real `any` and its eslint-disable.

Verified: turbo lint (14/14), turbo test (19/19), local:parity (103
endpoints), engine build — all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@willwashburn, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 13 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 97ccf934-7233-4ee5-acb0-047602e92195

📥 Commits

Reviewing files that changed from the base of the PR and between 06c40b2 and e1d9296.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • .trajectories/index.json
  • packages/engine/src/engine/certify.ts
  • packages/engine/src/engine/inboundWebhook.ts
  • packages/engine/src/lib/httpError.ts
📝 Walkthrough

Walkthrough

This PR refactors error handling and type safety across the engine. It introduces centralized HTTP error utilities, generalizes the database type system to support driver-specific types, adds runtime validation for external data via Zod schemas, and consolidates scattered error responses across 20+ route handlers into a single standardized pattern.

Changes

Core Platform Refactoring

Layer / File(s) Summary
Database type system and JSON column definitions
packages/engine/src/ports/database.ts, packages/engine/src/adapters/node/database.ts, packages/engine/src/db/schema.ts
EngineDb becomes generic over .run() result type; NodeEngineDb = EngineDb<RunResult> specializes it for better-sqlite3. JSON columns across eight tables now use .$type<Record<string, unknown>>() with object defaults ({}) instead of string defaults ('{}').
Centralized HTTP error handling
packages/engine/src/lib/httpError.ts, packages/engine/src/engine.ts
New module defines CodedError, asCodedError(), codedError(), and errorResponse() for standardized error normalization and JSON responses. Global error handler in engine.ts now uses asCodedError() instead of type casting.
Engine error standardization
packages/engine/src/engine/action.ts, packages/engine/src/engine/inboundWebhook.ts
Action handlers throw codedError() for agent_not_found (404), action_not_found (404), action_denied (403), and forbidden (403) errors. Webhook error construction uses Object.assign for consistency.
Metadata field normalization
packages/engine/src/engine/agent.ts, packages/engine/src/engine/channel.ts, packages/engine/src/engine/message.ts, packages/engine/src/engine/thread.ts, packages/engine/src/engine/console.ts, packages/engine/src/engine/directory.ts, packages/engine/src/engine/dm.ts
All metadata returns replace type casts with nullish coalescing (?? {}), ensuring only null/undefined default to empty objects instead of all falsy values.
Runtime validation for untrusted data
packages/engine/src/adapters/node/files.ts, packages/engine/src/engine/certify.ts
Token payload validation migrated from TypeScript interface to Zod schema; verify() returns null on parse failure. A2A tests now validate JSON-RPC envelope and error-code shapes via Zod schemas instead of type casts.
Route error handler consolidation
packages/engine/src/routes/*.ts (20+ files)
All route handlers' catch blocks replace inline error JSON construction with centralized errorResponse(c, err). Removes ContentfulStatusCode imports across action, agent, certify, channel, console, file, eventSubscription, message, presence, reaction, receipt, routing, search, systemPrompt, thread, workspace, dm, groupDm, inbox, inboundWebhook, and directory routes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • AgentWorkforce/relaycast#138: Introduces the engine implementation framework that this PR refactors with improved error handling, type safety, and database generics.

Poem

🐰 Errors dance with structure now,

Types flow free from any cast,

Each webhook, route, and channel bows

To centralized paths at last.

The engine speaks in CodedError tongue,

Where safety and simplicity are sung! 🚀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main objective: refactoring the engine package to remove type and validation tech debt (unsafe casts, lacking validation). This reflects the primary focus of the changeset across all modified files.
Description check ✅ Passed The description provides comprehensive context for the changes: explains the motivation (type-safety tech debt), lists concrete improvements (new error helper, schema typing, zod validation, generic EngineDb), quantifies impact (−280 LOC, casts reduced from ~250 to ~50), details verification steps, and notes deliberate exclusions. It clearly relates to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/engine-strict-types-validation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors error handling across the engine by introducing a centralized CodedError interface and helper functions (asCodedError, codedError, errorResponse) in a new httpError.ts utility, replacing repetitive try-catch error response formatting in various route files. It also improves type safety by generifying the EngineDb port, using Zod schemas to defensively parse untrusted external A2A responses and token payloads, and refining metadata and capability field types in the database schema. Feedback was provided to make the asCodedError helper more robust when handling plain objects thrown as errors, ensuring custom properties like code or status are preserved.

Comment on lines +16 to +19
export function asCodedError(err: unknown): CodedError {
if (err instanceof Error) return err as CodedError;
return new Error(typeof err === 'string' ? err : 'Unknown error');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When err is a plain object (e.g., from serialized error payloads or third-party libraries that do not throw real Error instances), the current implementation of asCodedError discards all custom properties (such as code, status, cause, or data) and returns a generic new Error('Unknown error'). We can make this much more robust by checking if err is a non-null object and using Object.assign to preserve all of its custom properties on the newly created Error instance, while ensuring the message property is safely coerced to a string.

Suggested change
export function asCodedError(err: unknown): CodedError {
if (err instanceof Error) return err as CodedError;
return new Error(typeof err === 'string' ? err : 'Unknown error');
}
export function asCodedError(err: unknown): CodedError {
if (err instanceof Error) return err as CodedError;
if (err && typeof err === 'object' && !Array.isArray(err)) {
const message = 'message' in err ? String(err.message) : 'Unknown error';
const error = Object.assign(new Error(message), err);
error.message = message;
return error;
}
return new Error(typeof err === 'string' ? err : 'Unknown error');
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in e1d9296. asCodedError now wraps non-Error plain objects with Object.assign, preserving code/status/cause (and coercing message to a string), so a thrown { code, status, message } still surfaces those fields exactly as the old err as Error & {...} reads did. Excluded arrays so their indices are not copied onto the Error.

@github-actions

Copy link
Copy Markdown

Preview deployed!

Environment URL
API https://pr150-api.relaycast.dev
Health https://pr150-api.relaycast.dev/health
Observer https://pr150-observer.relaycast.dev/observer

This preview shares the staging database and will be cleaned up when the PR is merged or closed.

Run E2E tests

npm run e2e -- https://pr150-api.relaycast.dev --ci

Open observer dashboard

https://pr150-observer.relaycast.dev/observer

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/engine/src/engine/inboundWebhook.ts (1)

170-173: 💤 Low value

Consider using the codedError() helper for these thrown engine errors.

codedError(message, code, status) is exported and matches the manual Object.assign(new Error(...), { code, status }) pattern used in inboundWebhook.ts (both the webhook_no_agent and webhook_agent_provision_failed cases). Swap those to throw codedError(...) for consistency (and add the import if missing).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/engine/inboundWebhook.ts` around lines 170 - 173, Replace
the manual Object.assign(new Error(...), { code, status }) patterns in
inboundWebhook.ts (the errors for 'webhook_no_agent' and
'webhook_agent_provision_failed') with the exported helper codedError(message,
code, status); specifically, remove the Object.assign constructions and throw
codedError(...) instead, and add the codedError import at the top if it's not
already present to keep error creation consistent.
packages/engine/src/routes/a2a.ts (1)

148-154: ⚡ Quick win

Reuse errorResponse(c, err) for the plain HTTP-JSON catch blocks in packages/engine/src/routes/a2a.ts.

  • The five plain HTTP-JSON catches at lines 148-154, 169-175, 183-189, 204-210, and 263-269 are byte-identical to the { ok:false, error:{ code: error.code || 'internal_error', message: error.message } } + (error.status || 500) envelope that errorResponse(c, err) already builds.
  • Update those catch blocks to return errorResponse(c, err); and add errorResponse to the existing import (../lib/httpError.js) alongside asCodedError.
  • Keep the JSON-RPC catches (lines 321 and 428) on the asCodedError(...) as CodedError & { data?: unknown } path since they need the JSON-RPC jsonRpcError envelope and error.data.
♻️ Example for one route
-  } catch (err: unknown) {
-    const error = asCodedError(err);
-    return jsonResponse(c, {
-      ok: false,
-      error: { code: error.code || 'internal_error', message: error.message },
-    }, error.status || 500);
-  }
+  } catch (err: unknown) {
+    return errorResponse(c, err);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/engine/src/routes/a2a.ts` around lines 148 - 154, Replace the five
duplicate plain HTTP-JSON catch blocks in a2a.ts with the helper call
errorResponse(c, err): import errorResponse alongside asCodedError from
../lib/httpError.js, then change each of the catch blocks at the locations noted
to simply return errorResponse(c, err); do not modify the JSON-RPC handlers that
use asCodedError(...) as CodedError & { data?: unknown } because they still need
the jsonRpcError envelope and error.data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/engine/src/engine/certify.ts`:
- Around line 328-333: The current check in the certify logic (where
jsonRpcEnvelopeSchema.safeParse(response.body) is assigned to envelope and
hasPayload is computed) incorrectly treats envelopes with both result and error
as valid; change the payload test to require exactly one branch present (result
XOR error) — i.e., set hasPayload to envelope.success && ((envelope.data.result
!== undefined) !== (envelope.data.error !== undefined)) — and update the
returned message to reflect whether a single JSON-RPC payload branch was found
while keeping details: { body: response.body, parse_error: response.parse_error
} unchanged.

---

Nitpick comments:
In `@packages/engine/src/engine/inboundWebhook.ts`:
- Around line 170-173: Replace the manual Object.assign(new Error(...), { code,
status }) patterns in inboundWebhook.ts (the errors for 'webhook_no_agent' and
'webhook_agent_provision_failed') with the exported helper codedError(message,
code, status); specifically, remove the Object.assign constructions and throw
codedError(...) instead, and add the codedError import at the top if it's not
already present to keep error creation consistent.

In `@packages/engine/src/routes/a2a.ts`:
- Around line 148-154: Replace the five duplicate plain HTTP-JSON catch blocks
in a2a.ts with the helper call errorResponse(c, err): import errorResponse
alongside asCodedError from ../lib/httpError.js, then change each of the catch
blocks at the locations noted to simply return errorResponse(c, err); do not
modify the JSON-RPC handlers that use asCodedError(...) as CodedError & { data?:
unknown } because they still need the jsonRpcError envelope and error.data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 24e012ad-7441-4f1c-b70b-4b75de2bfb82

📥 Commits

Reviewing files that changed from the base of the PR and between 068e51f and 06c40b2.

📒 Files selected for processing (38)
  • packages/engine/src/adapters/node/database.ts
  • packages/engine/src/adapters/node/files.ts
  • packages/engine/src/db/schema.ts
  • packages/engine/src/engine.ts
  • packages/engine/src/engine/action.ts
  • packages/engine/src/engine/agent.ts
  • packages/engine/src/engine/certify.ts
  • packages/engine/src/engine/channel.ts
  • packages/engine/src/engine/console.ts
  • packages/engine/src/engine/directory.ts
  • packages/engine/src/engine/dm.ts
  • packages/engine/src/engine/inboundWebhook.ts
  • packages/engine/src/engine/message.ts
  • packages/engine/src/engine/thread.ts
  • packages/engine/src/lib/httpError.ts
  • packages/engine/src/ports/database.ts
  • packages/engine/src/routes/a2a.ts
  • packages/engine/src/routes/action.ts
  • packages/engine/src/routes/agent.ts
  • packages/engine/src/routes/certify.ts
  • packages/engine/src/routes/channel.ts
  • packages/engine/src/routes/console.ts
  • packages/engine/src/routes/directory.ts
  • packages/engine/src/routes/dm.ts
  • packages/engine/src/routes/eventSubscription.ts
  • packages/engine/src/routes/file.ts
  • packages/engine/src/routes/groupDm.ts
  • packages/engine/src/routes/inboundWebhook.ts
  • packages/engine/src/routes/inbox.ts
  • packages/engine/src/routes/message.ts
  • packages/engine/src/routes/presence.ts
  • packages/engine/src/routes/reaction.ts
  • packages/engine/src/routes/receipt.ts
  • packages/engine/src/routes/routing.ts
  • packages/engine/src/routes/search.ts
  • packages/engine/src/routes/systemPrompt.ts
  • packages/engine/src/routes/thread.ts
  • packages/engine/src/routes/workspace.ts

Comment thread packages/engine/src/engine/certify.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 38 files

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/engine/src/lib/httpError.ts
- httpError: asCodedError now preserves properties of thrown plain
  objects (code/status/message), matching the prior `err as Error & {...}`
  reads it replaces, instead of flattening them to a generic Error.
  (gemini-code-assist)
- certify: jsonrpc_shape test now requires exactly one of result/error
  (XOR) per JSON-RPC 2.0, rather than accepting both. (coderabbitai)
- engine/inboundWebhook: use the codedError() helper for both throw
  sites for consistency. (coderabbitai)

Declined: routing a2a.ts's plain-HTTP catch blocks through errorResponse
— they use the file's `jsonResponse` helper (raw Response,
`application/json`), not Hono `c.json` (`application/json; charset=UTF-8`),
so they are not equivalent and the file uses jsonResponse uniformly.

Verified: engine typecheck, eslint --max-warnings 0, vitest (15/15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@willwashburn

Copy link
Copy Markdown
Member Author

Review feedback addressed — e1d9296

# Source Item Action
1 gemini, cubic asCodedError dropped code/status on plain-object throws Fixed — preserves own props via Object.assign, coerces message
2 coderabbit jsonrpc_shape accepted both result+error Fixed — now requires exactly one (XOR) per JSON-RPC 2.0
3 coderabbit (nitpick) engine/inboundWebhook.ts should use codedError() Applied — both throw sites now use the helper
4 coderabbit (nitpick) route a2a catches through errorResponse Declined (see below)

On #4: the five plain-HTTP catches in routes/a2a.ts use the file's local jsonResponse helper — a raw new Response(JSON.stringify(...), { headers: { 'Content-Type': 'application/json' } }) — not Hono's c.json, which sets application/json; charset=UTF-8. They're therefore not byte-identical to errorResponse, and the a2a routes use jsonResponse uniformly for every response. Routing only the error path through c.json would change the Content-Type and break that consistency, so I kept them on asCodedError + jsonResponse.

All checks green locally: engine tsc --noEmit, eslint --max-warnings 0, vitest (15/15).

@willwashburn willwashburn merged commit 97428f0 into main May 31, 2026
6 checks passed
@willwashburn willwashburn deleted the chore/engine-strict-types-validation branch May 31, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant