Skip to content

feat(execution): propagate trigger context from CLI, HTTP, and MCP hosts#399

Draft
aryasaatvik wants to merge 3 commits intoRhysSullivan:mainfrom
aryasaatvik:feat/trigger-propagation-cli-mcp
Draft

feat(execution): propagate trigger context from CLI, HTTP, and MCP hosts#399
aryasaatvik wants to merge 3 commits intoRhysSullivan:mainfrom
aryasaatvik:feat/trigger-propagation-cli-mcp

Conversation

@aryasaatvik
Copy link
Copy Markdown
Contributor

@aryasaatvik aryasaatvik commented Apr 24, 2026

Stack

Depends on #398#396 — merge those first. Cross-fork GitHub PRs can't use a branch on a contributor fork as a base, so these five PRs display as independent in the UI. The real dependency chain is the commit graph.

# PR Purpose
1 #396 feat(sdk): ExecutionStore backed by DBAdapter
2 #398 feat(execution): persist engine runs + tool calls
3 #399 ← you are here feat(execution): trigger propagation (CLI/HTTP/MCP)
4 #400 feat(apps): execution tables in drizzle schemas
5 #401 feat(api): /executions list/get/tool-calls endpoints

Until #398 and #396 land, this diff includes their commits. After both merge, this diff shrinks to the trigger-wiring changes.


Summary

Flows the trigger: { kind, meta } option the engine added in #398 end-to-end, so the execution row's trigger_kind column reflects the surface that actually kicked off the run. This is what the runs UI will facet on.

What ships in this PR

HTTP API (packages/core/api):

  • /executions POST now declares x-executor-trigger as an optional header in the schema.
  • Handler reads it (defaulting to "http") and passes it as the engine's trigger option.

MCP host (packages/hosts/mcp):

  • Explicit trigger: { kind: "mcp" } on engine.execute (inline elicitation path) and engine.executeWithPause (paused flow).

CLI: already sends x-executor-trigger: cli from the merge commit — this PR just makes the server side honor it.

Engine robustness improvement

While wiring this up, the MCP stdio integration test surfaced a subtle bug: Effect.ignore only catches typed failures. storage-drizzle throws a synchronous Error when a model isn't in the schema, which Effect promotes to a defect — and defects were bypassing `ignore`, leaking the raw error text into tool results.

This PR introduces a silent helper (Effect.catchAllCause(() => Effect.void)) and swaps every execution-history bookkeeping call to use it. Net effect: misconfigured storage now means no row gets persisted — the execution itself still succeeds and returns its real result.

Before: [storage-drizzle] unknown model "execution" — not found in db._.fullSchema… leaked to the MCP execute tool result text.
After: the result is 4 as expected, recording silently fails until the app's drizzle schema gets the tables (#400).

Test plan

  • bun x vitest run in @executor/execution — 15/15.
  • bun x vitest run in @executor/sdk — 97/97.
  • bun x vitest run in @executor/api — 4/4.
  • bun x vitest run in @executor/hosts/mcp — 23/23 including the previously-failing stdio-integration.test.ts > execute tool returns result over stdio transport.
  • bun x tsc --noEmit clean across the stack.

Adds execution history persistence to the core SDK surface, wiring
three new tables (`execution`, `execution_interaction`,
`execution_tool_call`) into `coreSchema` and exposing an
`ExecutionStore` service on `executor.executions`.

Changes:
- `core-schema.ts`: three new tables with `scope_id` / `execution_id`
  / `tool_path` / `trigger_kind` / `created_at` indexes for the runs
  UI's faceting + timeline queries.
- `ids.ts`: branded `ExecutionId`, `ExecutionInteractionId`,
  `ExecutionToolCallId`.
- `executions.ts`: `Execution`, `ExecutionInteraction`,
  `ExecutionToolCall` Schema classes, status enums,
  create/update/filter/sort/meta input types, and the
  `ExecutionStore` Context.Tag.
- `execution-store.ts`: `makeExecutionStore(core)` — an
  adapter-backed `ExecutionStoreService` implementation. Wraps
  `typedAdapter<CoreSchema>` for CRUD, handles cursor-based
  pagination, filter predicates (status, trigger, tool-path glob,
  time range, code substring, hadElicitation), and builds list meta
  with facets + chart buckets.
- `cursor.ts`: base64url `{ createdAt, id }` pagination cursors.
- `executor.ts`: constructs the store once per executor, exposes via
  `executor.executions`.
- `executions.test.ts`: round-trip + lifecycle coverage against the
  in-memory adapter (no migrations needed).

Follow-up work (future PRs in the stack):
- wire the engine to record runs + tool calls through this store,
- add `/executions` API endpoints, and
- land the runs UI.
Wires `executor.executions` into the Effect-native engine so every
`execute()` / `executeWithPause()` / `resume()` call writes an
`execution` row and its associated tool-call + interaction rows to
whichever `DBAdapter` backs the SDK.

Engine additions:
- `ExecutionTrigger` type + new `trigger?` option on `execute` and
  `executeWithPause`. Callers attribute runs ("cli", "http", "mcp",
  …); the kind + optional meta blob are persisted on the row.
- A stable `crypto.randomUUID()` execution id is minted at entry and
  reused as `PausedExecution.id`, so callers and the DB share the
  same identifier and counts line up across pause/resume.
- `makeRecordingInvoker` wraps the `SandboxToolInvoker` passed to the
  code executor; each `invoke` writes a tool-call row (running →
  completed|failed with duration). Storage errors are ignored so
  bookkeeping failures can never fail the tool call itself.
- `persistTerminalState` runs once on fiber success or failure and
  writes final status, result/error, logs, toolCallCount, completedAt.
- Pausable path: on elicitation, the execution transitions to
  `waiting_for_interaction` and a pending interaction row is created;
  `resume` resolves it (or cancels it if action === "cancel") before
  unblocking the fiber. A `toolCallCounters` map keeps the same Ref
  across pause/resume so the final count is accurate.
- Inline path: wraps the caller-supplied `onElicitation` so every
  inline elicitation gets the same pending → resolved bookkeeping.

Tests (`engine-persistence.test.ts`, 5 cases) cover:
- completed run + tool call rows
- error result → status=failed, errorText captured
- toolCallCount rolls up correctly
- trigger kind + meta persist on the row
- failed tool call records status=failed with errorText
Flows the trigger: { kind, meta } option the engine added in the
previous PR end-to-end so the runs UI can facet by attribution
surface. Also promotes recording writes from Effect.ignore to a
defect-absorbing variant so a misconfigured storage backend can't
take down an execution.

Surfaces:
- HTTP API (packages/core/api): /executions POST now declares an
  x-executor-trigger optional header. Handler reads it (defaulting to
  "http") and passes it as the engine's trigger option.
- MCP host (packages/hosts/mcp): explicit trigger: { kind: "mcp" } on
  engine.execute (inline elicitation path) and engine.executeWithPause
  (paused flow).
- CLI: stamps every /executions call from executeCode with
  x-executor-trigger: cli. Covers call, search, describe, sources —
  every subcommand that runs code goes through this helper.

Engine robustness:
- Introduced silent helper (Effect.catchAllCause(() => Effect.void))
  and swapped every bookkeeping .pipe(Effect.ignore) over to it.
  Effect.ignore only catches typed failures; a synchronous throw
  inside an adapter (e.g. storage-drizzle when the schema is missing
  the execution model) becomes a defect and was bypassing ignore.
  With silent, misconfigured storage just means no row — the
  execution itself succeeds.

Verified by the MCP stdio integration test which previously leaked
the [storage-drizzle] unknown model error into the MCP tool result
text. Now returns the expected code result.
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