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 frame SignalFrame{path, signal, data}: signal discriminates the type, data carries the payload. Cross-Pod via Redis Pub/Sub.

Status: v1 in production. First consumer: Compose Run Status (signal: "compose-run") — see damogran-system.md.

See also live-ws.md (envelope frame, channel inventory) and pointers-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 inbound signal type: the client only subscribes to receive; signals are generated by the Brain. A future client→server signal (true run-kill) would add an inbound signal case 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 documents channel.
  • 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