Signals Channel — Generic Ephemeral Per-Document Signals
Live WS channel
signals: subscribe per document path and receive ephemeral, document-related signals. Pure fan-out — no persistent state, no history, best-effort, no DB write. A generic frameSignalFrame{path, signal, data}:signaldiscriminates the type,datacarries the payload. Cross-Pod via Redis Pub/Sub.Status: v1 in production. First consumer: Compose Run Status (
signal: "compose-run") — seedamogran-system.md.See also
live-ws.md(envelope frame, channel inventory) andpointers-channel.md(same roster-free fan-out pattern). History:planning/agent-compose-run.md§5.
1. Purpose
A channel for ephemeral signals belonging to a document and intended for its current viewers — without touching the document state. The prime example: “a Compose Run is currently active on this page”. Such transient signals do not belong in a document write (which incurs Redis publish costs even without viewers, is an archive version candidate, and conflicts with an open unsaved editor buffer) and not in Presence (which answers “who is watching”, not “what is happening”).
The channel is intentionally generic: new signal types (e.g., a future
compose-kill/run-kill) ride along using the same signal discriminator,
without new wire types. “pointers” is the high-frequency cursor channel;
signals is the low-frequency event channel.
2. Wire Format
Each frame is contained in a LiveEnvelope with
channel: "signals":
{ "channel": "signals",
"payload": { "type": "signal",
"data": { "path": "notes/build.compose.yaml",
"signal": "compose-run",
"data": { "runId": "cr-1a2b", "status": "running",
"workspace": "build" } } } }
Type (type) |
Direction | Payload |
|---|---|---|
subscribe |
C → S | { path } |
unsubscribe |
C → S | { path } |
unsubscribe-all |
C → S | empty |
signal |
S → C | SignalFrame = { path, signal, data? } |
signal— String discriminator for the signal type ("compose-run", …).data— optional, typed additional map (Map<String, Object>); the server passes it through 1:1 without reading it. The semantics are defined by the respective consumer (see §5).- Server-originated. Unlike
pointers, there is (in v1) no inboundsignaltype: the client only subscribes to receive; signals are generated by the Brain. A future client→server signal (true run-kill) would add an inboundsignalcase in the channel handler. - No self-filter needed — the producer is the server, not a client connection.
3. Server Architecture
Mirrored to ws.pointers, but without rate-cap/leave:
| Class | Responsibility |
|---|---|
SignalChannelHandler |
Frame demux (subscribe/unsubscribe/unsubscribe-all), subscribe cap (100/Connection) |
SignalBroadcaster |
Per-path subscriber set (RAM), broadcast(tenantId, SignalFrame) → local fan-out + Redis publish, tenant-scoped |
The only server-side state is the per-connection subscription set in RAM (fan-out addressing + cleanup-on-disconnect). A broadcast to a path without local subscribers is a no-op — a producer pays nothing if no one is watching.
Tenant-Scoping: Paths can collide across Tenants; the fan-out only delivers
to subscribers whose ConnectionContext Tenant matches the signal Tenant
(stricter than pointers because signals carry Run IDs).
3.1 Redis Pub/Sub (Cross-Pod only)
Topic: vance:{tenantId}:signals
Payload: "{podId}|{tenantId}|{base64(json(SignalFrame))}"
Self-echo via podId (Per-Process-UUID) discarded — like documents.changed
and pointers. vance.redis.enabled=false → the channel operates pod-locally
(fan-out on the same Pod), but not cross-pod. Sufficient for single-pod dev.
4. Client Architecture
In wsConnectionStore (vance-face): subscribeSignals(path) /
unsubscribeSignals(path) + onSignal(path, handler) — per-path callbacks,
reconnect resubscribe (desired-set) as with pointers. A signal frame is
dispatched to registered handlers based on data.path.
5. Consumer: Compose Run Status
compose_block_run (see damogran-system.md §9)
emits on the path of the Compose document:
signal |
data |
When |
|---|---|---|
compose-run |
{ runId, status: "running", workspace } |
Immediately after run start (before fast-path wait) |
compose-run |
{ runId, status: "done" \| "failed", workspace } |
Upon completion (inline or onDone) |
The ComposeView (Cortex) subscribes to the path of its open document and
immediately shows “running” when running + polls the live tail via
GET /brain/{tenant}/compose/run/{runId}. The final $output does not
come via this signal, but via the documents.changed reload (the server writes
$output back server-authoritatively). The signal is purely during-run
feedback; not result transport.
6. Not in Scope (intentionally)
- Persistence / History / Replay of signals — fire-and-forget.
- Reliable delivery / Buffering.
- Roster / Presence — “who is watching” is the concern of the
documentschannel. - Result/State Transport — signals are triggers/status, not payload channels; actual state flows via REST/document.
- Client→Server Signals (v1) — only subscribe + server push; inbound will come when a consumer (run-kill) needs it.
7. Code Anchors
| Aspect | File |
|---|---|
| Channel Handler | vance-brain/.../ws/signals/SignalChannelHandler.java |
| Broadcaster + State | vance-brain/.../ws/signals/SignalBroadcaster.java |
| Channel Demux (Routing) | vance-brain/.../ws/LiveWebSocketHandler.java (signals-Case + Unsubscribe-on-Close) |
| Wire DTOs | vance-api/.../ws/SignalFrame.java, SignalSubscribeRequest.java |
| MessageType | vance-api/.../ws/MessageType.java (SIGNAL*-Constants) |
| Producer (compose-run) | vance-brain/.../damogran/ComposeBlockRunTool.java (emitSignal) |
| Client Wiring | vance-face/src/ws/wsConnectionStore.ts (signals-Section) |
| Consumer | vance-face/src/cortex/components/ComposeView.vue |
| Tests | vance-brain/.../ws/signals/SignalBroadcasterTest.java |