Skip to content

refactor(bus): one GlobalBus emit per event, with optional sync metadata#28203

Draft
kitlangton wants to merge 1 commit into
devfrom
worktree-global-bus-unify
Draft

refactor(bus): one GlobalBus emit per event, with optional sync metadata#28203
kitlangton wants to merge 1 commit into
devfrom
worktree-global-bus-unify

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

DRAFT / PROTOTYPE — opening this to evaluate the shape of the change. Builds on #27959 (bus eager-subscribe) and is orthogonal to #28187 (sync EffectBridge).

The problem

Today every sync.run produces two GlobalBus events with two different payload shapes:

  1. From bus.publish itself — the projection view:
    { payload: { id, type: "session.created", properties } }
    
  2. From sync/index.ts — the source-of-truth envelope:
    { payload: { type: "sync", syncEvent: { type: "session.created.1", id, seq, aggregateID, data } } }
    

Consumers discriminate via payload.type — either a real event type or the sentinel "sync". The two emits live in different files. The dual-emit is the source of "wait, which payload is this?" confusion.

History

Two emits with different shapes on one channel isn't quite a single stream. One emit per event with a unified shape is.

What this PR does

Collapses the two emits into one:

{ payload: { id, type: "session.created", properties, sync?: { name, seq, aggregateID, data } } }
  • bus.publish accepts an optional sync?: SyncMetadata option.
  • sync/index.ts passes it; the second GlobalBus.emit is removed.
  • BusEvent.effectPayloads() adds an optional sync field to every event schema.
  • SyncEvent.effectPayloads() is no longer included in the wire schema (subsumed).
  • Consumers migrate from payload.type === \"sync\" to payload.sync != null. workspace.ts reconstructs SerializedEvent from { id, sync.* } for replay.

Breaking change

This is a breaking SDK wire-format change. External consumers reading `payload.type === "sync"` or `payload.syncEvent` will need to migrate.

Inside opencode, all internal consumers were migrated in this PR:

  • `cli/cmd/tui/context/event.ts`
  • `cli/cmd/run/stream.transport.ts`
  • `control-plane/workspace.ts`
  • `app/src/context/global-sdk.tsx` (the web app)

Side-note: schema drift fixed

While doing this I noticed the runtime emit was using `payload.syncEvent` (nested) while the SDK schema (`SyncEvent.effectPayloads()`) declared the fields at top level under `type: "sync"`. They had drifted. Collapsing to one emit fixes this by definition.

Test plan

  • `bun typecheck` from root — clean (all workspaces)
  • `bun run test test/sync/index.test.ts test/bus/bus-effect.test.ts test/server/httpapi-event.test.ts test/server/httpapi-event-diagnostics.test.ts` — 29/29 green
  • `bun run test test/server/httpapi-sdk.test.ts -t "streams sync-backed"` — green
  • SDK regenerated via `packages/sdk/js/script/build.ts`

Net diff

~10 files. Includes regenerated SDK types.

Why draft

This is a wire-protocol change. Worth a deliberate decision on:

  1. Whether the SDK breakage is acceptable now or needs coordination/version bump.
  2. Whether the projection+envelope coupling is the right axis.
  3. Whether to ship after fix(bus): acquire PubSub subscription eagerly to close /event race #27959 and refactor(sync): publish via EffectBridge.fork for codebase consistency #28187 land.

Happy to convert to ready-for-review or close, whichever you prefer.

Today every sync.run produces TWO GlobalBus events:

  1. From bus.publish itself — the projection view:
     { payload: { id, type: "session.created", properties } }

  2. From sync/index.ts — the source-of-truth envelope:
     { payload: { type: "sync", syncEvent: { type: "session.created.1", id, seq, aggregateID, data } } }

These two emits live in different files and discriminate via
`payload.type` — either a real event type or the sentinel "sync".
That collision is the source of confusion in the codebase:
consumers have to know which shape they're looking at.

The duality dates back to:
  - d88912a (Dec 2025): Dax added GlobalBus.emit inside bus.publish.
  - b22add2 (#22347, Apr 2026): James consolidated sync events onto
    GlobalBus by ADDING a second emit with the source envelope.

#22347's stated intent was "consolidate events into a single stream".
Two emits with different shapes on one channel isn't quite that. One
emit per event with a unified shape is.

This PR collapses the two emits into one:

  { payload: { id, type: "session.created", properties, sync?: { name, seq, aggregateID, data } } }

- bus.publish accepts an optional `sync?: SyncMetadata` option.
- sync/index.ts passes it; its own GlobalBus.emit is removed.
- BusEvent.effectPayloads() adds an optional `sync` field to every
  event schema.
- SyncEvent.effectPayloads() is no longer included in the wire schema
  (it's now subsumed by the optional field).
- Consumers migrate from `payload.type === "sync"` filtering to
  `payload.sync != null` filtering. control-plane/workspace.ts
  reconstructs SerializedEvent from { id, sync.* } for replay.

Note: this is a BREAKING SDK wire-format change. External consumers
that read `payload.type === "sync"` or `payload.syncEvent` need to
migrate. Internal opencode consumers all migrated in this PR.

Side observation: the runtime emit was using `payload.syncEvent`
(nested) while the SDK schema declared the fields at top level under
`type: "sync"` — a silent schema drift. Collapsing to one emit also
fixes that drift by definition.

Verified:
  - bun typecheck — clean
  - bun run test test/sync/index.test.ts test/bus/bus-effect.test.ts
    test/server/httpapi-event.test.ts
    test/server/httpapi-event-diagnostics.test.ts — 29/29 green
  - bun run test test/server/httpapi-sdk.test.ts -t "streams sync-backed" — green
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant