Skip to content

feat(studio-events): one push channel to the portal, and stop polling background runs - #131

Open
AndrejK666 wants to merge 3 commits into
constructorfabric:mainfrom
AndrejK666:AndrejK666/studio-events
Open

feat(studio-events): one push channel to the portal, and stop polling background runs#131
AndrejK666 wants to merge 3 commits into
constructorfabric:mainfrom
AndrejK666:AndrejK666/studio-events

Conversation

@AndrejK666

Copy link
Copy Markdown
Contributor

What

A single SSE channel from the assembly to the portal, and the first consumers of it.

GET /cf/studio-events/v1/stream                      SSE, scoped to the caller's tenant
GET /cf/studio-events/v1/events?after_seq=&limit=    the same events, by cursor

studio-tasks announces every run transition on it, so the portal is told instead of polling GET /studio-tasks/v1/runs/{id}. In the prototype the repository-sync runner and the Background work list now follow the stream; the shell (studio-frontend) gets the transport and a registered service, ready for its first screen.

Design is written up in ADR-0013 — the short version:

  • The channel is domain-neutral. An event is { seq, at_ms, kind, subject_type, subject_id, source, payload }. A producer says what happened, to what, who says so; everything type-specific is payload. seq/at_ms are the channel's, so a producer cannot forge ordering.
  • One producer, not four. The studio-tasks dispatcher is the one place every background run passes through, so imports, catalogue syncs, notification deliveries and scheduled runs are all covered without the gears that own the work knowing this exists.
  • Theia is a producer, not the contract. studio-theia's sink republishes as theia.<kind> about a workspace, with session id and sequence inside payload. A bridge to an IDE container that most deployments do not run has no business shaping the stream everything else uses.
  • Built to be replaced. The platform's event-broker gear is still a skeleton (every REST handler is todo!("lands with #4346")) while its SDK is done. When it lands this becomes a consumer→SSE bridge and the two endpoints do not change. Its own /v1/events:sse still should not go to a browser — JOIN/SEEK/topology frames are an SDK protocol.

Frontend transport

SseProtocol opens new EventSource(url), which cannot send Authorization, and the gateway takes no token from the query string — auth.ts documents the gap, and its transport binder only accepts REST plugins. So SseAuthPlugin short-circuits onConnect with a fetch-backed EventSourceLike (the same seam SseMockPlugin uses) that also owns:

  • reconnect — the protocol treats onerror as fatal and disconnects, which also defeats the native reconnect, so retries live in the transport and onerror fires only on 401/403;
  • exactly-once, ordered redelivery — the gap is replayed by cursor, live frames are held until the replay is delivered, the overlap is dropped.

None of that is visible to useApiStream or to an MFE.

One rule worth knowing: read the cursor before enqueuing. A run that fails in 300 ms is over before the stream is open; ?resume_from= is what replays it.

Verification

  • Backend gates in the CI image: fmt, clippy --all-targets -D warnings, check for --features theia-bridge and --no-default-features, cargo test504 passed.
  • On a running stack (own Postgres, assembly booted): a repository import with a rejected credential produced running ×5, requeued ×4, 10 task.progress frames and one terminal task.failed, counts arriving as the handler reported them. Backfill by cursor returns exactly the gap; latest_seq reports the high-water mark; no token → 401.
  • The gateway's global 30 s request timeout does not affect an established stream (it is tower::timeout, which covers only producing the response) — connection held for minutes with keep-alives every 15 s. Both nginx configs already pass streams through (proxy_buffering off).
  • Frontend: tsc clean; shell 29 tests, host app 2 new service tests, 6 transport tests, prototype 46 tests — all green. The four connections-mfe failures that predate this branch are unchanged.

Notes for review

  • Fan-out is in-process and per tenant, so it assumes one backend replica (backend.replicas: 1 today). More than one needs sticky sessions or the broker.
  • The replay window (500 events/tenant, in memory) is a reconnect patch, not an event store.
  • Progress announcements are as frequent as a handler reports; consumers that reload on them should coalesce, as the prototype's run list does.

Every consumer of background work polls: the prototype's repository sync
every 1.2 s for five minutes, its Background work view every 4 s while
anything is live. A run that fails in 300 ms still reads "queued…" until
the next tick.

This adds the channel that replaces those loops — one per assembly, not
one per producer:

  GET /cf/studio-events/v1/stream          SSE, scoped to the caller's tenant
  GET /cf/studio-events/v1/events?after_seq=&limit=   the same, by cursor

An event is { seq, at_ms, kind, subject_type, subject_id, source, payload }
and nothing in that shape knows about tasks, repositories or IDE sessions.
A producer states what happened, to what, and who says so; everything
type-specific is payload. `seq` and `at_ms` are the channel's, so a
producer cannot forge ordering or backdate an event.

Three properties are deliberate:

* Tenant isolation is structural — a broadcaster per tenant rather than a
  filter someone has to remember.
* A reconnect loses nothing: a bounded per-tenant window (500 events) is
  replayable by cursor, and `latest_seq` running ahead of the last
  delivered `seq` is how a client learns it fell out of that window.
* Frames are unnamed and the event type is the JSON's `kind`, because the
  frontend SDK's SSE protocol binds only `onmessage`, which never fires
  for a named frame.

Publishing is `dyn StudioEventPublisher` from the ClientHub: synchronous,
infallible, resolved lazily by producers so gear init order does not
matter, and absent in an assembly without this gear — which then behaves
exactly as before.

The platform's event-broker will replace the inside of this: its gear is
still a skeleton (every REST handler is `todo!("lands with #4346")`) while
its SDK is done. When it lands this becomes a consumer→SSE bridge and the
two endpoints above do not change.

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
The dispatcher is the one place every background run passes through, so
that is where the announcement belongs: `record` for state transitions and
the progress sink for phase reports. Imports, catalogue syncs, notification
deliveries and scheduled runs are all covered without a single gear that
owns work having to know the channel exists.

An event carries the fields the run endpoint answers with — state,
attempts, phase, summary, error, result — so a view can be fed by either
without a second mapping. Only what the transition actually set is
included: an absent field means "unchanged", and a consumer merges rather
than overwrites. Sending null for what a patch left alone would tell a
client the run had just lost its counts.

The publisher is resolved per event rather than held, because gear init
order is not guaranteed and an assembly without the channel must lose the
announcement and nothing else.

studio-theia's default sink moves the same way: a forwarded Theia event is
republished as `theia.<kind>` about a workspace, with session id, sequence
and the raw callback argument inside `payload`. The bridge's vocabulary
stops at that boundary — one producer's protocol has no business becoming
the contract every consumer lives with. The event-broker sink stays behind
its feature flag for when the broker lands.

Verified on a running stack: a repository import with a rejected
credential produced running ×5, requeued ×4, 10 progress frames and one
terminal task.failed, with the counts arriving as the handler reported
them.

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
…port

The frontend had the consuming half ready — useApiStream, StreamDescriptor,
SseProtocol — and no way to reach an authenticated stream. `SseProtocol`
opens `new EventSource(url)`, which cannot carry `Authorization`, and the
gateway takes no token from the query string; `auth.ts` says as much, and
its transport binder only accepts REST plugins.

So the transport is ours, plugged in where the protocol already allows it:
`SseAuthPlugin.onConnect` short-circuits with a fetch-backed
`EventSourceLike`, exactly as `SseMockPlugin` does with a mock. It adds the
two things the protocol does not do:

* Reconnect. `attachHandlers` treats `onerror` as fatal and disconnects,
  which also defeats the native reconnect, so a dropped connection is
  retried here with backoff and `onerror` is fired only when the session is
  over (401/403) and retrying would just hammer the gateway.
* Exactly-once, ordered redelivery. The gap opened by a disconnect is
  replayed from the cursor endpoint, live frames are held until that replay
  is delivered, and the overlap is dropped by cursor. None of it is visible
  to useApiStream or to an MFE.

The starting cursor rides in the URL (`?resume_from=`), which the server
ignores and the transport reads: that makes it part of the descriptor key,
so useApiStream opens a fresh connection when it changes, and two
subscribers with different starting points cannot fight over one field.

`StudioEventsApiService` is registered on the shell so every MFE shares one
stream. In the prototype two poll loops become subscriptions: the
repository-sync runner (both surfaces that start a sync) and the Background
work list, which coalesces a burst of progress into one reload.

One rule the prototype proves and the docs now state: read the cursor
*before* enqueuing. A run that fails in 300 ms is over before the stream is
open, and `fromSeq` is what replays it.

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 61a94117-15f5-4967-a58a-3176d9eb544b


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

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