collab-gateway¶
Status: Current | Last reviewed: 2026-10-05
Real-time presence + Yjs CRDT gateway. Drives the avatar stacks on agents, workflows,
catalogs, and code-server — and owns the autosave flush that writes
agent_versions.config / workflow_versions.config.
Tier 1: a converter that drops a key deletes authored config from Postgres while the editor still shows it.
Source¶
Runtime¶
| Aspect | Value |
|---|---|
| Language | Node (TypeScript) |
| Port | 3510 |
What it does¶
- Tracks who is in which room:
agent:<id>,workflow:<id>,catalog:<id>,code-server:<workspace>. - Brokers Yjs document updates and flushes them to Postgres — the agent and workflow
editors autosave through this path, so it is the write authority for
configJSONB, not a mirror of it. - Verifies browser sessions via
POST /api/auth/collab-verifyto ai-manager. - Verifies code-server identity via
x-collab-service-token+x-on-behalf-ofheaders.
Durable saves¶
Durable saves compare the current JSON projection with the DB-hydrated or last
persisted version in src/draft-save.ts. Redis synchronization is independent:
loading a clean snapshot is not a config edit. Restoring Redis first reads a
separate DB projection so pending edits survive a gateway restart. Clean,
reordered-key or reverted-to-baseline projections do not write or record activity.
Concurrent flush requests wait for the same drain, including edits arriving
during the write; failures remain pending.
The agent persistence endpoint adds IS DISTINCT FROM guards to config and
identity updates and returns changed: false for unchanged/rejected writes.
Edit-session snapshots include name/description so identity changes have a
meaningful diff. Both room and activity recording skip equal before/after hashes.
Existing history is retained.
Validation: src/draft-save.test.ts covers no-ops, recovery, failures and concurrent
edits. AI Manager's e2e/agent-autosave-idle.spec.ts checks real DB timestamps,
no history on load/view/clean exit, and a rename with before/after snapshots.
Dependencies¶
| Service | Purpose |
|---|---|
| Redis DB 4 | Presence keys (45s TTL); fan-out collab:fanout channel |
| Postgres | Yjs durable state via collab_documents |
| ai-manager | Browser session verification |
Vanguard¶
overmind restart collab-gateway
ai-manager points at it via NEXT_PUBLIC_COLLAB_WS_URL=ws://localhost:3510.
Save failing while Overmind reports running¶
Check http://localhost:3510/healthz and the collab-gateway log. On
2026-10-01, an interrupted HTTP request body raised ECONNRESET from an async
request handler and terminated the server. Its tsx watch parent stayed
running, while ai-manager's save endpoints returned 503 with ECONNREFUSED.
Restarting collab-gateway restored editor connections.
The HTTP request boundary now catches rejected handlers. An incomplete, disconnected request is discarded; other failures return a generic 500 or close an already-started response. Regression tests send a partial POST, disconnect, and verify a subsequent health request succeeds.
Verify recovery against the affected draft in Postgres, not only the save
indicator. In the same incident, reconnecting left two identical stage
entries in the shared document. The duplicate-stage guard acknowledged but
refused that flush, so alive: true and dirty: false did not prove a write.
Back up both the Redis Y.Doc and database draft before repairing shared
state. Remove a duplicate only after comparing its full content; divergent
copies require reconciliation. A targeted Yjs deletion can remove an
identical copy while preserving the rest of the live document.
Production¶
Helm chart at charts/collab-gateway. Two replicas; sticky sessions are NOT required because fan-out is via Redis pubsub.
Permission gate — joining and writing are separate checks¶
The gateway asks ai-manager for the room's tenant and the caller's permissions, then applies
two checks, both in authz.ts:
| Scope | Join (canJoinRoom) |
Write (canWriteRoom) |
|---|---|---|
agent |
AIManager:RunAgents |
AIManager:ManageAgents |
workflow |
AIManager:RunAgents |
AIManager:ManageAgents |
catalog |
AIManager:ManageTools |
AIManager:ManageTools |
code-server |
service token + x-on-behalf-of |
always — the extension host is already trusted |
Platform Admin has full room permissions. Viewer can join agent and workflow rooms for read-only presence; updates are rejected by the write gate. Builder can write to both agent types and catalogs. Every identity still selects the tenant that owns a room before joining it. A successful join does not imply write access. Reconnect open editor sessions when rolling out role changes so the gateway verifies their current permissions.
This gate was wrong from 2026-07-01 to 2026-08-12, and its failure mode is silent. ai-manager consolidated 20+ permissions into five on 2026-07-01, and
expandRolePermissionsupgrades every retired name away before an identity reaches this service — soidentity.permissionsholds only the five. The gateway's copy of the compatibility map was never updated and ran the other way (ManageAgents→EditAgents), leaving both gates asking for retired names. Agent and workflow rooms passed by accident, through anEditAgents → ViewAgentsimplies chain; catalog rooms asked for the retired multimodal permission, which nothing can issue, so the A2UI catalog editor's presence roster was empty for every non-platform-admin. Neither gate logs a denial: a failed join is an empty avatar stack, and a failed write is dropped updates under a "Saved" chip. The map now mirrors ai-manager's, andauthz.test.tsreadslib/permissions.tsas text to assert they still agree — a one-sided edit there fails here.
Yjs shape is an allow-list — new config keys must be added or they vanish¶
Workflow and agent autosave flows through the gateway: editor edits → Y.Doc → debounced flush → PATCH /api/auth/collab-config on ai-manager. The Y.Doc ↔ JSON converters (src/shape/workflow-shape.ts, pipeline-shape.ts) enumerate a fixed allow-list of top-level config keys. Any key the converter doesn't know about is silently dropped on every flush — the editor shows the change, the DB never receives it.
This bit the workflow triggers and execution_mode fields (2026-06-02): adding a "Conversation Start" trigger appeared to work in the editor, but autosave rebuilt the config without it, so publish projected zero trigger_rules and the workflow never appeared in the agent editor's conversation-lifecycle dropdown. Fixed by adding both to workflow-shape.ts.
It also bit the agent config's transfer (Handoff panel), injection (Injection policy editor), and resilience (Operations panel) sections — all written through the collab path but absent from pipeline-shape.ts, so any collab autosave dropped them (fixed 2026-06-11). Panel-scoped top-level agent-config sections now round-trip through an opaque misc Y.Map branch: MISC_ROOT_FIELDS (transfer, injection, resilience, ces, warm_transfer, ivr, local_action_tool_ids), defined identically in both copies of pipeline-shape.ts (gateway + apps/ai-manager/lib/collab/pipeline-shape.ts). These sections are edited atomically by one form each, so whole-value (non-CRDT) storage is fine.
Agent-local inline tool definitions live inside a stage's nested config.inline_tools[] while the selected refs live in config.tools as inline:<id>. If a stale browser/editor flush keeps an inline:<id> ref but drops the matching definition, ai-manager's PATCH /api/auth/collab-config repairs the payload from the previous draft before updating agent_versions.config. If the previous draft cannot supply the definition, the handler refuses that flush while returning success to avoid an autosave retry loop. Regression coverage lives in inline-tool-preservation.test.ts.
A conditionally-emitted key is the same trap wearing a different hat. The translation sub-pipelines round-trip through dedicated translation_in/translation_out Y.Arrays plus translation_{in,out}_meta Y.Maps, and *YDocToJson only rebuilt config.translation when one of those had content. A freshly-created translation agent seeds both flows empty (translation.flows.{incoming,outgoing}.pipeline = [], no echo/language meta), so the first autosave flush dropped the whole translation key. That silently downgraded the agent — isUnifiedTranslationConfig() (which requires translation.flows.incoming && .outgoing) went false, so the agent editor rendered the plain FlowEditor instead of TranslationPipelineEditor: no Echo Settings, no incoming/outgoing direction toggle, no translation preview chrome (and the orchestrator lost the structure too). Fixed 2026-07-08 by emitting translation whenever out.subtype === 'translation' || out.agent_type === 'translation_agent' (both ride the root Y.Map, so they're authoritative even with empty flows), in both copies of pipeline-shape.ts. Lesson: if a key's presence gates which editor renders, its emit must key on a stable marker (subtype), not on whether the user has added content yet. Regression coverage in pipeline-shape.test.ts ("fresh translation agent (empty flows) keeps translation key").
The same ai-manager receiver refuses agent flushes containing duplicate stage ids (config.pipeline[*].id) while returning success to the gateway. Duplicate ids make the editor collapse stages during reconciliation and can make ai-orchestrator reject a linear LLM/S2S branch at call time with Only one LLM-like service supported per branch/segment; the stale Y.Doc must be reset after repairing the draft row.
When you add a new top-level field to WorkflowConfig or the agent pipeline config, add it to the matching shape converter in both directions (*JsonToYDoc and *YDocToJson; for agent configs, usually just append the key to MISC_ROOT_FIELDS in both files) and add a round-trip assertion in pipeline-shape.test.ts. The shape is not a passthrough.
The tests, and the drift guard between the two copies¶
Two suites cover the converters, and they are not redundant — they run over different copies of the same allow-lists:
apps/ai-manager/lib/collab/pipeline-shape.test.tscovers ai-manager's copy, which drives the browser bridge.apps/collab-gateway/src/shape/shape.test.ts(added 2026-08-12; the gateway had no tests before) covers this copy, which writes the database — including the empty-settingsemit, the nested[[a],[b]]branch group, placeholder-stage pruning at every depth, the translation subtype marker, and the workflowtriggers/inline_toolsarrays. Its last test reads both files as text and asserts the ten allow-lists match, because a field added to one copy round-trips fine in the editor and is deleted on the next flush. Same idiom as ai-manager'spayments-mirror.test.ts.
Both are gated in test.yml (unit (ai-manager),
unit (collab-gateway)). Run the gateway's with npm test in apps/collab-gateway
(node --import tsx --test; no new dependency — tsx was already there). npm run build
uses tsconfig.build.json so the test file stays out of the image.
A room caches its tenant — a null one self-heals on rejoin¶
Each live Room (src/rooms.ts) is created with the owning tenantId resolved at the first join (via POST /api/auth/collab-room-tenant), and the DB flush sends it as the x-collab-room-tenant header. The ai-manager receiver rejects any flush missing that header with 400 {"error":"room tenant required"}. getRoom keys the registry on scope:roomId only, so whatever tenant the room was born with sticks for its whole lifetime.
A room can be born with tenantId: null — e.g. it's created during a failed hydrate (an unstable gateway mid-hot-reload logs workflow:<id> DB hydrate failed — starting with empty Y.Doc) or a transient lookup miss. From then on every autosave flush is rejected room tenant required, the Y.Doc never persists (Redis snapshot + lastFlushAt both stay empty), and the editor sits at "Saving…" forever — even though the DB row has a perfectly valid tenant_id and the lookup endpoint returns it. Observed 2026-06-08 on workflows 1731/1740.
Fix (2026-06-08): Room.tenantId is mutable and getRoom calls room.adoptTenant(tenantId) on every rejoin, filling a null from the next authorized join. It never overwrites a known tenant (cross-tenant hazard). Don't revert tenantId to readonly or drop the adoptTenant call in getRoom. Note: the in-memory Y.Doc of an already-stuck room is not recoverable by the fix alone — a gateway restart drops it back to the last DB draft, because the unsaved edits were never flushed.
See also¶
apps/collab-gateway/README.md— file-by-file layout, env vars, and the HTTP surface- Presence and collaboration
- Service tiers
- code-server
- ai-manager file layout