v1.13.11-b: convert raw broker.publish call sites to typed publishFrame

Second half of the WebSocket-frame-typing batch. Phase A (8b568b3)
landed the schemas + frontend receive validation + publishFrame /
publishUserFrame wrappers. This commit converts the existing publish
call sites so every server-emitted WS frame now goes through Zod
validation at the broker boundary.

Conversion strategy: change once in the inference / skills adapters in
index.ts (so ctx.publish / ctx.publishUser propagate to publishFrame /
publishUserFrame for ALL ~50 inference + auto_name call sites in one
move), then bulk-replace the ~30 direct broker.publish* call sites in
the routes + compaction.

Files touched:
- index.ts: inference + skills route adapters now call publishFrame /
  publishUserFrame internally; raw broker.publishUser('default', ...)
  call in the stale-row sweeper also converted.
- routes/projects.ts (7 sites), routes/chats.ts (9 sites),
  routes/sessions.ts (8 sites): all broker.publishUser(...) → broker.
  publishUserFrame(...).
- services/compaction.ts (3 sites): 2 publishUser, 1 publish.

Real protocol drift surfaced by Zod, fixed in the same commit:

  services/compaction.ts:442 was publishing chat_status with status:
  'working' — the v1.12.1 chat_status widening (CLAUDE.md:55) dropped
  this enum value in favor of streaming|tool_running|waiting_for_input|
  idle|error. The compaction.ts site was missed during v1.12.1; the
  frame had been published with an unknown enum value ever since (the
  frontend useChatStatus quietly ignored it). Corrected to 'streaming'
  — compaction's LLM call has the same dot-state semantic as an
  inference turn. This is exactly the class of bug v1.13.11 exists to
  catch.

Schema relaxation: OpaqueObject (the bag type for nested entities like
Project / Chat / Session / WorkspacePane embedded in WS frames) was
z.object({}).passthrough(), which Zod outputs as {} & {[k:string]:
unknown}. The strict-typed entities don't have index signatures so
TypeScript rejected them at publishFrame call sites. Relaxed to
z.unknown() — runtime validation still accepts the value, dev-time
narrowing happens via the existing hand-maintained types. Trade-off:
frame-level drift detection stays sharp; nested-payload validation
goes to follow-up work as the brief intended.

Schema audit:
  grep -rn "broker\.publish(\|broker\.publishUser(" apps/server/src \
    --include="*.ts" | grep -v "broker.ts\|__tests__\|.bak"
  → 0 results. Every server publish goes through publishFrame /
  publishUserFrame. The remaining ctx.publish / ctx.publishUser sites
  in services/inference/* + services/auto_name.ts route through the
  index.ts adapter, which calls publishFrame internally.

Tests: 219/219 pass (unchanged from v1.13.11-a; the Phase B conversion
is mechanical and doesn't add test cases).

Smoke: clean container boot, no ws-frame-validation-failed entries
under normal traffic. Sidebar list refresh + agent picker open both
pass through useUserEvents without drops.

~70 LoC across 7 files. v1.13.11 closed.
This commit is contained in:
2026-05-22 15:54:00 +00:00
parent 8b568b36d3
commit bc376c878d
7 changed files with 62 additions and 51 deletions

View File

@@ -112,7 +112,7 @@ export function registerSessionRoutes(
`;
return session!;
});
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_created',
session: row,
project_id: row.project_id,
@@ -178,7 +178,7 @@ export function registerSessionRoutes(
}
const session = rows[0]!;
if (name !== undefined && session.name !== priorName) {
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_renamed',
session_id: session.id,
name: session.name,
@@ -188,7 +188,7 @@ export function registerSessionRoutes(
// (notably the SettingsPane open in another tab) can refetch and pick
// up the new fields. Frame stays lean (decision d) — payload is just
// ids + name + updated_at, the client refetches via api.sessions.get.
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_updated',
session_id: session.id,
project_id: session.project_id,
@@ -220,7 +220,7 @@ export function registerSessionRoutes(
return { error: 'session not found' };
}
const session = rows[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_workspace_updated',
session_id: session.id,
workspace_panes: session.workspace_panes,
@@ -248,7 +248,7 @@ export function registerSessionRoutes(
`;
const ids = rows.map((r) => r.id);
for (const id of ids) {
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: id,
project_id: req.params.id,
@@ -289,7 +289,7 @@ export function registerSessionRoutes(
reply.code(404);
return { error: 'session not found or already archived' };
}
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_archived',
session_id: rows[0]!.id,
project_id: rows[0]!.project_id,
@@ -312,7 +312,7 @@ export function registerSessionRoutes(
return { error: 'session not found or not archived' };
}
const session = rows[0]!;
broker.publishUser('default', {
broker.publishUserFrame('default', {
type: 'session_created',
session: session,
project_id: session.project_id,
@@ -334,7 +334,7 @@ export function registerSessionRoutes(
return { error: 'not found' };
}
const project_id = deleted[0]!.project_id;
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
reply.code(204);
return null;
}