feat(execution-history): move run detail to R2, slim the D1 index - #24
Conversation
The runs collection drops its bulky drawer-only fields (full code, result/error/logs/trigger-metadata) and the per-tool-call / per-interaction collections. Those now live in one append-only R2 object per run (via the plugin blob store, keyed run-detail/<id>): a code-only stub on ExecutionStarted so a live run shows its code and survives a restart, the full detail on finish. The D1 runs row is now a slim index — list/aggregate fields plus a bounded codePreview and denormalized logErrorCount/logWarnCount, so the list renders entirely from D1 with no per-row blob fetch. The drawer reads the full detail in one request (get flat-merges the R2 detail onto the slim run); the separate tool-calls endpoint + atom are removed. New slim fields are optional-with-default so pre-migration rows degrade gracefully instead of failing the response encoder. Breaking: the runs list/detail response shapes change and past runs are cleared (no backfill).
One-off boot data migration deleting all executionHistory plugin_storage rows: the runs schema changed to a slim index + R2 detail object with no backfill, so stale fat rows and orphaned tool-call/interaction rows are dropped.
Greptile SummaryThis PR splits execution-history storage to match each subsystem's access pattern: a slim D1
Confidence Score: 5/5Safe to merge; the D1/R2 split is well-structured and the typed Effect error channels are used correctly throughout. The refactor is internally consistent: the slim RunRow schema, the R2 codec, the write/read helpers, and the HTTP response shape all align. The hard-cut data migration is appropriate given the breaking schema change, and the changeset documents the trade-offs clearly. The one write-ordering concern (D1 final state committed ahead of the R2 detail write) is an edge case that only materialises under a transient R2 failure at the exact moment a run finishes; in practice the store degrades gracefully rather than corrupting data. packages/plugins/execution-history/src/sdk/store.ts — specifically the write ordering in onExecutionFinished and the untested buffer-lost restart path (noted in prior review comments). Important Files Changed
Sequence DiagramsequenceDiagram
participant Engine
participant Store
participant D1
participant R2
Engine->>Store: ExecutionStarted
par D1 + R2 concurrent
Store->>D1: "putRun (status=running, codePreview, counts=0)"
Store->>R2: putDetail (code-only stub)
end
Engine->>Store: ToolCallStarted / ToolCallFinished
Note over Store: buffered in-memory only
Engine->>Store: InteractionStarted (optional)
Store->>D1: "putRun (status=waiting_for_interaction)"
Note over R2: stub unchanged — toolCalls still []
Engine->>Store: ExecutionFinished
Store->>D1: putRun (final state: status, timing, logCounts)
alt buffer present (normal path)
Store->>R2: putDetail (full: code + result + logs + toolCalls)
else buffer lost (restart path)
Store->>R2: readDetail (recover code/triggerMeta from stub)
Store->>R2: "putDetail (result + logs, toolCalls=[])"
end
participant Client
Client->>Store: get(executionId)
Store->>D1: runsC.get
Store->>R2: blobs.get (run-detail/id)
Store-->>Client: flat-merged ExecutionHistoryDetail
Reviews (3): Last reviewed commit: "refactor(execution-history): require the..." | Re-trigger Greptile |
…elog The changeset documents the slim-index + R2-detail change. The service-tokens package was missing its CHANGELOG.md seed (pre-existing on dev), which fails the repo-wide lint:changelog-stubs gate for any PR branched off dev — seed it.
… shim codePreview/logErrorCount/logWarnCount were optional-with-default to tolerate pre-migration rows. Since this is a breaking change that clears old runs (no backfill), that shim isn't required — make the fields plain required and drop the undefined-coalescing at the list use sites. The cutover wipe (self-host migration + a wipe-before-deploy on the dogfood) is authoritative.
Splits execution-history storage so each store fits its access pattern: - **D1 `runs` row → a slim index.** Drops the bulky, drawer-only fields (full `code`, `resultJson`, `errorText`, `logsJson`, `triggerMetaJson`) and removes the `toolCalls` / `interactions` collections. Keeps the list/aggregate fields plus two bounded denormalized fields — `codePreview` and `logErrorCount`/`logWarnCount` — so the list renders entirely from D1 with **no per-row blob fetch**. - **R2 detail object → the bulky payload.** One append-only JSON object per run (via the existing plugin blob store, keyed `run-detail/<id>`): a code-only stub on `ExecutionStarted` (so a live/paused run shows its code and survives a worker restart), the full detail on `ExecutionFinished`. Serialized via Effect Schema (`fromJsonString`), never raw JSON. - **Drawer reads it in one request.** `get` flat-merges the R2 detail onto the slim run; the separate tool-calls endpoint + atom are removed. The bulky fields are write-once, never mutated, and read only by id in the drawer — the shape object storage is built for. Moving them to R2 keeps the D1 row tiny (~hundreds of bytes), removing the D1 size ceiling as a practical concern, while the slim denormalized fields keep the list a pure D1 read. The runs list/detail response shapes change and **past runs are cleared (no backfill)**: - self-host: a one-off boot data migration deletes the `executionHistory` rows. - The slim fields are `optional`-with-default, so any pre-migration row still decodes/encodes (degrades to an empty preview) rather than 400-ing the list. - typecheck: 0 errors · lint: 0/0 · format: clean · tests: 19/19 (the store test round-trips a run's detail through the blob store). A separate PR adds the first execution-history e2e scenario (run → open drawer → assert code/logs/tool-calls). The optional WAE aggregate-reader remains deferred behind a documented trigger.
Splits execution-history storage so each store fits its access pattern: - **D1 `runs` row → a slim index.** Drops the bulky, drawer-only fields (full `code`, `resultJson`, `errorText`, `logsJson`, `triggerMetaJson`) and removes the `toolCalls` / `interactions` collections. Keeps the list/aggregate fields plus two bounded denormalized fields — `codePreview` and `logErrorCount`/`logWarnCount` — so the list renders entirely from D1 with **no per-row blob fetch**. - **R2 detail object → the bulky payload.** One append-only JSON object per run (via the existing plugin blob store, keyed `run-detail/<id>`): a code-only stub on `ExecutionStarted` (so a live/paused run shows its code and survives a worker restart), the full detail on `ExecutionFinished`. Serialized via Effect Schema (`fromJsonString`), never raw JSON. - **Drawer reads it in one request.** `get` flat-merges the R2 detail onto the slim run; the separate tool-calls endpoint + atom are removed. The bulky fields are write-once, never mutated, and read only by id in the drawer — the shape object storage is built for. Moving them to R2 keeps the D1 row tiny (~hundreds of bytes), removing the D1 size ceiling as a practical concern, while the slim denormalized fields keep the list a pure D1 read. The runs list/detail response shapes change and **past runs are cleared (no backfill)**: - self-host: a one-off boot data migration deletes the `executionHistory` rows. - The slim fields are `optional`-with-default, so any pre-migration row still decodes/encodes (degrades to an empty preview) rather than 400-ing the list. - typecheck: 0 errors · lint: 0/0 · format: clean · tests: 19/19 (the store test round-trips a run's detail through the blob store). A separate PR adds the first execution-history e2e scenario (run → open drawer → assert code/logs/tool-calls). The optional WAE aggregate-reader remains deferred behind a documented trigger.
What
Splits execution-history storage so each store fits its access pattern:
runsrow → a slim index. Drops the bulky, drawer-only fields (fullcode,resultJson,errorText,logsJson,triggerMetaJson) and removes thetoolCalls/interactionscollections. Keeps the list/aggregate fields plus two bounded denormalized fields —codePreviewandlogErrorCount/logWarnCount— so the list renders entirely from D1 with no per-row blob fetch.run-detail/<id>): a code-only stub onExecutionStarted(so a live/paused run shows its code and survives a worker restart), the full detail onExecutionFinished. Serialized via Effect Schema (fromJsonString), never raw JSON.getflat-merges the R2 detail onto the slim run; the separate tool-calls endpoint + atom are removed.Why
The bulky fields are write-once, never mutated, and read only by id in the drawer — the shape object storage is built for. Moving them to R2 keeps the D1 row tiny (~hundreds of bytes), removing the D1 size ceiling as a practical concern, while the slim denormalized fields keep the list a pure D1 read.
Breaking change + cutover
The runs list/detail response shapes change and past runs are cleared (no backfill):
executionHistoryrows.optional-with-default, so any pre-migration row still decodes/encodes (degrades to an empty preview) rather than 400-ing the list.Verification
Follow-up
A separate PR adds the first execution-history e2e scenario (run → open drawer → assert code/logs/tool-calls). The optional WAE aggregate-reader remains deferred behind a documented trigger.