## Summary & Motivation
Stream infrastructure failures (HTTP/2 session wedges, transport timeouts, non-2xx stream responses) surfaced as plain `Error`, so terminal classification attributed them to customer code as `USER_ERROR`. They now carry a catchable `StreamError` with a `STREAM_ERROR` run error code, attributed to the SDK and retried when transport-level or 5xx.
The v4 events response body is wrapped so a post-header stream failure is classified and reported to the dispatcher recycler — a response header arriving is not yet a successful streamed request.
## Test Plan
Unit tests added across classification, serialization round-trip, the streamer, and the v4 transport; 331 `@workflow/core` and 123 `@workflow/world-vercel` focused tests pass.
* [core] Make a duplicate attr_set inert instead of terminal
A workflow-body attribute write draws a correlation id that resolves exactly
once: the dispatcher's consumer takes the matching event and deregisters. A
second event under that id therefore has no callback left and never will.
`attr_set` had no entry in ENTITY_EVENT_CLASS_BY_TYPE, so the duplicate skip
could not take it, and `PARKABLE_EVENT_TYPES` does list the type, so it was
parked for a consumer that could never come. Parking is settled by the workflow
function returning, and a survivor there is reported through `strandedEvent` as
a replay divergence. So the run did all of its work, every step succeeded, and
the final replay failed it, deterministically enough to burn the whole
replay-divergence recovery budget and terminate with CORRUPTED_EVENT_LOG.
Give `attr_set` a class so the straggler is skipped like every other one:
committed but inert. Parking still covers the first arrival, for a replay that
walks past an attribute event before the body reaches the call that claims it.
An attribute write from a step body carries no correlation id and is consumed by
the structural lifecycle consumer, so it is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* [core] Release a parked duplicate, and agree with the UI about one
The class map alone decides a straggler only where the walk meets it after a
consumption recorded the class. When neither copy has a consumer yet both
park — the walk steps over the first and re-enters in the same tick, with
nothing consumed and so no class recorded — and the drain then claims one and
holds the other for a callback that will never be registered. That survivor is
`strandedEvent`, which is the CORRUPTED_EVENT_LOG this branch set out to stop,
reached by the other road. `dropParkedDuplicates` releases it on the same terms
the walk skips one. Not an `attr_set` property: `wait_completed` parks in pairs
too, and `ONE_SHOT_EVENT_TYPES` only sees the order where the consumption came
first.
Giving `attr_set` a class also moved the observability UI, which reads the same
`entityEventClass` to grey out events a run passed over. It kept treating the
straggler as live, because its terminal-class set had no `attr_set` while the
dispatcher's consumer does deregister on the first event under an id. The two
now share `classifyEntityEvent` and `TERMINAL_EVENT_CLASSES` rather than each
keeping a copy of the rule.
That sharing needs the entity rule to be exact, because a step-written
`attr_set` carries no correlation id: keyed on the run it would collapse every
attribute write a run made into one class, and a captured production log in
`__fixtures__` holds forty. `classifyEntityEvent` gives such an event no class
at all, so neither side can read the second as a repeat of the first.
The shared fixture corpus had nothing for `attr_set`, which is why the drift
between the two halves went unseen. It has four now, and each of them fails on
both sides without the fix above it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
* fix(web-shared): keep Alt+hover working after selecting a span
Chrome's Alt menu-bar handling blurred the page and cleared the overlay
flag, which also killed span hover until the window was refocused.
* test(web-shared): drop useAltHeld unit tests
* refactor(web-shared): let useAltHeld own Alt tracking
Drop the event union, reducer, and timeline mouse-move graft. The hook
listens for keys, blur, and window pointermove itself and returns only
altHeld.
* style(web-shared): trim useAltHeld comment
* fix(web-shared): keep Alt gap overlay when nothing is selected
pointermove was copying e.altKey, so Chrome's post-preventDefault
pointermove with altKey false cleared the flag on Alt press.
* Revert "fix(web-shared): keep Alt gap overlay when nothing is selected"
This reverts commit ba142906f7.
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* docs: apply Vercel technical writing standards
Audit the complete documentation corpus, package READMEs, skills, and
source TSDoc/comments against the vercel-technical-writing skill and
style-rules.md. Normalize sentence-case headings without changing
published anchors, remove prose em dashes and filler wording, improve
active voice and self-contained phrasing, standardize product/brand
capitalization, American English, list punctuation, units, and code
fence languages, and preserve exact runtime strings/table placeholders.
All executable code is unchanged. Modified skills have their metadata
versions bumped.
* docs: extend writing audit to repository Markdown
Apply the same technical-writing rules to design documents, compiler
specifications, workbench guides, package changelogs, and the remaining
tracked Markdown outside the deployed docs corpus. Preserve historical
meaning, commands, output literals, table placeholders, and heading
anchors.
* docs: exclude generated package changelogs from audit
* fix(web-shared): use UTC tooltips for trace viewer markers
Replace the relative-time context card on hook and attribute ticks with the
shared tooltip, labeled `Hook received [UTC] …` and `Attribute set [UTC] …`.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Mitul Shah <mitulxshah@gmail.com>
* fix(web-shared): format marker helpers and drop invalid aria-label
Biome requires the kind filter ternary on one line, and aria-label is not
valid on the tooltip trigger span.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
* fix(web-shared): prefix marker context cards with event kind
Restore the relative-time context card on hook and attribute ticks, and
prefix the relative time with "Hook received" or "Attribute set".
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
* refactor(web-shared): slim marker context-card prefix
Drop the label helper, type guard, and unused DefaultTimeText prefix path.
Keep kind on the marker, map copy at the tick, and prefix only the card heading.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
* refactor(web-shared): drop prefix comments and generic event-mark helper
Leave sortedEventMarks as a plain string filter. The prefix prop does not
need a JSDoc restatement.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
---------
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Drop RunTraceView and WorkflowTraceViewer, move shared Span/Trace types into lib/trace-types, and keep timing helpers under the new viewer.
Signed-off-by: mitul-s <mitulxshah@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary & Motivation
One warm compute instance serves many invocations, so the Compute Instance ID already in this panel can't distinguish steps that ran inline within a single flow-function invocation — sibling steps sharing a Request ID did. It's also the value Vercel Logs indexes by, and this panel's View Logs button is where a reader takes it next.
The key is `vercelId`, not `requestId`: that's the name world-vercel stores the SDK's request id under crossing the wire, and AnalyticsEvent's sibling `requestId` field is declared but never written. Nothing in this repository populates `vercelId` on the object the panel receives yet — only AnalyticsEvent carries it, and grouping steps by invocation needs a step-level aggregation in workflow-server first — so the row ships as forward-compatible plumbing in the slot next to Compute Instance ID.
The second commit is an independent fix: `sortByAttributeOrder` guarded `indexOf` with `|| 0`, but a miss returns -1, which is truthy, so any key absent from `attributeOrder` sorted ahead of every listed key. It can be dropped on its own.
## Test Plan
Unit tests added; the two ordering assertions fail against the unfixed comparator. The new row could not be verified by hand — no local code path populates it.
* feat(core): lazy hook resumption via parallel event write + queue publish (rebased onto #1834 + #3145)
Rebase of #3230 onto current main (267765375 + #1834 resilient resumeHook
+ #3145 event-count-gated replay restart). Reconstructed as a single commit
since `git rebase -i` is unavailable in this environment.
Reconciliation vs the pre-rebase branch:
- Replaces #1834's version-prediction (`supportsQueueHookInput`,
`QUEUE_HOOK_INPUT_MIN_VERSION`) with #3230's capability protocol
(persisted `hookResumeInputVersion` + static `hookResumeDedup`).
- One idempotency protocol: a single `resumeId` + SHA-256 payload digest
per resume, sent to both the direct event write and the queue `hookInput`.
- Two execution tiers: backend+consumer attest dedup -> parallel
`Promise.allSettled(event write, queue publish)`; otherwise plain
sequential (no hookInput/resumeId, event-write errors propagate).
- Consumer re-ensures the `hook_received` event (keyed by resumeId/digest)
after event loading, before replay; skips when already preloaded.
- Preserves #3145: event-count guard, `preconditionReinvocations`,
in-process replay restart, `insertEventByEventId`.
- Removes #1834's resumeId-only test (never released); adds parallel +
consumer-preload + world-local dedup/producer-consumer suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(core): read top-level event.resumeId in replay dedup; reconcile unreleased #1834 docs/changeset
- hook.ts: dedup hook_received replay on top-level event.resumeId (the
backend now hoists it to a first-class column), with the legacy nested
eventData.resumeId retained as a deprecated parse-only fallback.
- workflow.test.ts: cover dedup across both top-level and legacy nested forms.
- resume-hook.ts: emit producer recovery telemetry when a transient
event-write failure is swallowed on the parallel path.
- resume-hook.consumer-preload.test.ts: add terminal-run (consume) and
transient-conflict (rethrow/redeliver) re-ensure cases.
- Consolidate the two overlapping changesets into resilient-resume-hook.md
and delete the redundant lazy-hook-resumption.md.
- Docs: return type back to Promise<Hook> (resume-hook.mdx), rewrite the
resilience changelog to the final parallel/deduplicated design, and correct
the WORKFLOW_DISABLE_LAZY_HOOK_RESUME resilience wording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs,core: rename "Resilient hook resume" → "Lazy hook resume" for consistency
- changelog/index.mdx: update the changelog entry title.
- hook.ts: update the dedup comment label to "Lazy-resume dedup".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: give #3230 its own changeset instead of repurposing #1834's
The lazy-hook-resume work had been folded into #1834's pre-existing
`resilient-resume-hook.md` changeset. Give this PR its own changeset and
delete the superseded #1834 one, whose `resilientResume: true` flag promise
no longer holds (resumeHook() returns plain Promise<Hook>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: add #3230's own lazy-hook-resumption changeset
Follow-up to 63d877178, which deleted #1834's superseded changeset but did
not stage the replacement. Adds this PR's own changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: tighten lazy-hook-resumption changeset
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: leave #1834's resilient-resume changeset/changelog/docs untouched
Restore #1834's own artifacts that #3230 had rewritten:
- .changeset/resilient-resume-hook.md (restored verbatim)
- docs/.../changelog/resilient-resume.mdx (restored verbatim)
- docs/.../changelog/index.mdx (restored verbatim)
#3230 keeps only its own changeset plus the two docs its code/config genuinely
require: the resumeHook() Promise<Hook> return type (ResumedHook is removed
from the code) and the new WORKFLOW_DISABLE_LAZY_HOOK_RESUME env var.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Reconcile #1834 ResumedHook contract with #3230 parallel resume
Preserve the resilient-resume contract from #1834 on the parallelized
resumeHook() fast path instead of dropping it:
- Restore the `ResumedHook` type (Hook + optional `resilientResume`) and its
exports (`@workflow/core/runtime`, `workflow/api`); resumeHook/resumeHookImpl
return `Promise<ResumedHook>`.
- Set `resilientResume: true` on the swallow-recover branch (transient direct
write failure + successful queue dispatch), absent on the happy/sequential
paths.
- Restore the producer OTEL convention `workflow.hook.resilient_resume` and the
consumer `workflow.hook.resilient_resume_materialized`, wired where the
consumer re-ensures the event.
- Restore the consumer `occurredAt` derivation from the resume ULID so the
materialized hook_received is dated to resume time, not queue-round-trip time.
- Fix the #3230 changeset's contradictory "Still returns Promise<Hook>" line and
update the resilient-resume changelog + resume-hook API reference to the
shipped parallel/dedup behavior.
- Port the #1834 failure-path coverage into resume-hook.parallel.test.ts
(non-retryable event-write rethrow, both-fail prioritizes the queue error,
resilientResume flag + payload delivery on the recovered path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address review: drop dead nested resumeId fallback, remove server PR link
- Drop the legacy nested `eventData.resumeId` fallback in the hook consumer.
The nested form was only ever written by unreleased preview builds and is
stripped by `EventSchema` parsing (the `hook_received` eventData schema does
not declare it), so the fallback was dead code. Dedup now keys solely off the
top-level `event.resumeId` column. Repoint the replay dedup test to the
surviving top-level path (it previously exercised the nested form only by
building unparsed Event objects in memory).
- Remove the internal workflow-server PR reference from world-vercel's
capability note (the link 404s outside the org); the note keeps the same
information without the dead link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Fix Biome lint violations and add Biome CI check
Biome was not configured to respect .gitignore, so ~92% of the 13,355
reported diagnostics came from gitignored build artifacts. Enable VCS
integration (useIgnoreFile), apply safe auto-fixes across the repo, fix
the remaining mechanical errors by hand, downgrade judgment-call a11y /
dangerouslySetInnerHTML rules to warnings, and add a 'biome ci' job to
the Lint workflow so violations block PRs going forward.
* Use an empty changeset (no behavior change, no release needed)
* feat(world): persist the compute instance that ran each step attempt
Add CreateEventParams.computeInstanceId (ambient per-event identity, mirroring requestId) and a readable Event.computeInstanceId. Core stamps it on every step_started write; world-vercel forwards it in the v4 frame meta next to vercelId. Lets observability distinguish steps sharing a compute instance from those on different instances or invocations.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
* test: cover computeInstanceId threading from params to v4 frame meta
world-vercel: computeInstanceId reaches the v4 frame meta, rides alongside vercelId rather than replacing it, and is omitted when unset. core: step_started carries it without displacing the stateUpdatedAt precondition guard (both share one params object).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
* fix(web-shared): render computeInstanceId in the attribute panel
AttributeKey derives from keyof Event, so adding computeInstanceId to the event schema widened it and left the exhaustive attributeToDisplayFn map incomplete (TS2741). Renders it beside deploymentId as 'Compute Instance ID', copyable like the other opaque ids.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
* fix(world-postgres): exclude computeInstanceId from the events column contract
The events table asserts satisfies DrizzlishOfType<...Omit<Event, 'occurredAt'>...>, so adding computeInstanceId to the event schema broke the build (TS1360). This world does not persist it, matching how occurredAt is already handled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
* refactor: move computeInstanceId to the analytics read contract
The server routes computeInstanceId into ClickHouse and returns it on AnalyticsEvent/AnalyticsStep, never on the event record — so Event.computeInstanceId was dead on read and zod would strip the field off the analytics wire. Move it to AnalyticsEventSchema/AnalyticsStepSchema (beside vercelId/requestId, the same class of ambient provenance), which also drops the world-postgres column-contract exclusion entirely.
Also: hoist the duplicated step_started params into one local, extract the repeated mock-agent harness in events.test.ts, and use vi.spyOn plus an identity assertion against COMPUTE_INSTANCE_ID.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
---------
Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hooks can carry an optional `resumeContext` mirrored from the run at
creation time. When present, `resumeHook`/`resumeWebhook` resume directly
from it instead of fetching the full run, saving a round trip per resume.
When the context also carries the run's `encryptionPublicKey`, the resume
seals its payload (`encp`) directly to that key. Combined with the sealed
envelope work (#3093-#3096), a default webhook resume then needs neither a
run read nor a cross-deployment run-key lookup: the key is resolved only
when the hook actually stores metadata that must be hydrated symmetrically.
Everything falls back transparently to the full run fetch and symmetric
key when the context (or the public key within it) is absent, so new
clients interoperate with old servers and vice versa.
- world: optional `encryptionPublicKey` on `HookResumeContext`
- world-postgres: `resume_context` column migration
- core: combined fast-path + seal in resume-hook; fast-path control-flow
suite split from the real-serialization crypto suite
- world-vercel: cover the `getEncryptionKeyForRun(runId, { deploymentId })`
overload the fast path relies on
- web-shared: render `resumeContext` in the attribute panel
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: decrypt sealed payloads in the dashboard and CLI
Without this, any payload another run sealed to this one renders as a lock
icon with no way to open it — a visible regression for anyone debugging a
run that received a cross-deployment hook resumption. The user is entitled
to read the data and has already supplied the key; only the plumbing was
missing.
`hydrateDataWithKey` now delegates to the envelope layer, which dispatches
on the format prefix, instead of unconditionally running AES-GCM. All four
o11y key-resolution sites (web-shared hydration, the web stream reader,
and both CLI `--decrypt` paths) resolve the full capability rather than
just the symmetric key. Each already had the raw 32 bytes in hand, so this
costs one extra derivation and no additional requests.
A caller that supplies only a symmetric key still gets the ciphertext
placeholder for sealed payloads rather than a decryption error, since that
key never could have opened them.
**Browser bundling.** The obvious import for the new helper is
`@workflow/core/serialization`, but that module graph reaches `node:util`
and `node:async_hooks` and cannot be bundled for the browser — which is
what `@workflow/core/serialization-format` exists to avoid. The key
helpers are re-exported from that browser-safe entrypoint instead, and the
two browser consumers import from there; the CLI keeps the direct import
since it runs on Node. Verified by walking the built import graph: the
entrypoint reaches 6 modules and zero Node built-ins.
Unrelated: `pnpm --filter @workflow/web build` currently fails on `main`
too (`reducers/common.js` importing `node:util`). Turbo caching had been
hiding it; touching core caused a cache miss that surfaced it. Not
addressed here.
* review: narrow the o11y decrypt key type and dedupe an import
- `hydrateDataWithKey` accepted `PayloadKey`, which includes `SealTarget`.
A seal target holds only a public key, so it can open neither scheme —
passing one compiled fine and then always failed at runtime. Added a
`DecryptionKey` alias (`CryptoKey | RunPayloadKeys`) and narrowed the
signature, so that misuse is now a compile error. A `@ts-expect-error`
test pins the guarantee.
- `hydrateResourceIOAsync` dynamically imported
`@workflow/core/serialization-format` twice. Destructure both bindings
from the single existing import instead.
* review: record @workflow/web in the changeset
This PR changes the dashboard's stream reader
(`packages/web/app/lib/hooks/use-stream-reader.ts`) so it dispatches on the
envelope format and can read sealed (`encp`) frames, but the changeset listed
only core, web-shared and cli.
`@workflow/web` is published, so without an entry the change would still ship —
just as an incidental dependency bump, with nothing in that package's release
notes explaining that sealed-stream decryption landed.
* feat: publish each run's X25519 public key on the run entity
A cross-run writer needs the recipient run's public key to seal a payload
to it. Derive that key at `start()` and stamp it on the run, so a hook
resumption or a forwarded-stream writer can find it on a run fetch it was
already making instead of spending ~350ms on `run-key`.
The key is derived from the per-run key material `getEncryptionKeyForRun()`
already returns, so nothing about key acquisition changes. It is not
secret: the matching private scalar is never stored anywhere, only
re-derived on demand from the deployment's own env seed. Storing it beside
run metadata therefore does not weaken the run's confidentiality.
**Presence is the writer-side gate for sealed envelopes.** A run only
carries a public key if the runtime that created it could also open one —
which holds by construction, since derivation and `encp` dispatch both
live in `@workflow/core`, so any core that can stamp can also open. Runs
are pinned to their creating deployment, so the capability this attests to
is still accurate at resume time. Writers seal iff the field is set and
otherwise fall back to the symmetric path, which makes version skew
degrade gracefully instead of wedging a run.
The field rides on `run_created`, and is mirrored onto the queued
`runInput` so the resilient-start path (server recreates the run from the
queue message when the `run_created` write failed) doesn't silently
produce a run that can't receive sealed writes.
world-vercel's compile-time wire-contract guard caught the new field
before it could be silently dropped on the v4 path, exactly as designed —
routed into the frame meta block as plaintext metadata.
Also adds browser- and VM-safe base64 helpers to `sealed-box.ts`, since
neither `Buffer` nor `btoa` can be assumed in every context that module
runs in. `base64ToBytes` returns undefined on malformed input rather than
throwing, so a corrupt stored key degrades to "no usable public key" and
falls back to the symmetric path instead of crashing a resumption. Both
are cross-validated against `Buffer` in tests.
* review: fix public-key loss on resilient start and lifecycle updates
Two real bugs found in review, both in the local worlds. Neither surfaces
as an error — a run just silently stops accepting sealed cross-run writes
and falls back to the slow symmetric path forever.
**Resilient start dropped the key.** When a `run_started` arrives for a
run that was never created, world-local and world-postgres rebuild the run
from the queued message. Neither copied `encryptionPublicKey` onto the run
row or the synthetic `run_created` event they write. That is precisely the
scenario this field exists to survive. (The equivalent server-side path was
already handled.)
**world-local also wiped the key on every lifecycle transition.** Its
run_started / run_completed / run_failed / run_cancelled handlers rewrite
the whole run document field-by-field, so any field not explicitly listed
is dropped — meaning the key was lost on the *first* `run_started`, not
just on the resilient path. All four rebuild sites now carry it.
world-postgres is safe here by construction because it issues
column-scoped SQL UPDATEs rather than rewriting the row.
**base64 decoding is now strict.** The decoder accepted shapes that
cannot describe a whole number of bytes (`length % 4 === 1`) and ignored
anything after a mid-string `=`, returning a short array instead of
`undefined`. That is worse than throwing: a corrupt stored key looked
*present*, so callers sealed to garbage rather than taking the symmetric
fallback. Now rejects out-of-alphabet characters, bad lengths, misplaced
padding, and non-zero trailing bits — with a round-trip test over every
length 0–48 to make sure the strictness does not overshoot.
* fix: send encryptionPublicKey in the v4 POST frame meta
`splitEventDataForV4` lifted the run's public key into the frame meta and
`events.ts` spread that meta into `CreateEventV4Input`, but
`buildPostFrameMeta` — which copies meta onto the wire field by field — never
forwarded `encryptionPublicKey`, and the field was missing from
`CreateEventV4Input` entirely. Because the meta is applied with a spread,
TypeScript's excess-property check doesn't fire, so the key was computed, put
in the meta, and then silently dropped before the request was sent.
The server therefore never received the key, never stored it on the run
entity, and every cross-run writer fell back to the symmetric envelope. Every
symptom pointed away from the SDK: a deliberately oversized key was accepted
rather than rejected (the field never arrived), the key was absent from the run
row, and `resumeHook()` always chose `encr`.
Add the field to `CreateEventV4Input`, forward it in `buildPostFrameMeta`, and
cover it for both `run_created` and resilient-start `run_started`. Also add a
generic guard asserting that every field the splitter puts in the meta reaches
the wire, so the next omission in this hand-maintained mapping fails a test
instead of silently degrading encryption.
Hook timeline bars were amber/yellow via RESOURCE_COLORS; passive
spans should be gray to match the event list icons and minimap.
Co-authored-by: Cursor Agent <cursoragent@cursor.com>