* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval - Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts and fix all type errors - Fix getAccessTokenForSession: read GitHub token directly from account record instead of calling Better Auth's internal /get-access-token endpoint which returns 403 on server-side calls - Re-implement workspaceAuth helper functions (workspaceAuthColumn, normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were accidentally deleted - Remove all retry logic (withRetries, isRetryableAppActorError) - Implement CORS origin allowlist from configured environment - Document cachedAppWorkspace singleton pattern - Add inline org sync fallback in buildAppSnapshot for post-OAuth flow - Add no-retry rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Foundry dev panel from fix-git-data branch Port the dev panel component that was left out when PR #243 was replaced by PR #247. Adapted to remove runtime/mock-debug references that don't exist on the current branch. - Toggle with Shift+D, persists visibility to localStorage - Shows context, session, GitHub sync status sections - Dev-only (import.meta.env.DEV) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add full Docker image defaults, fix actor deadlocks, and improve dev experience - Add Dockerfile.full and --all flag to install-agent CLI for pre-built images - Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full - Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example - Expand Docker docs with full runnable Dockerfile - Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning) - Audit and convert 12 task actions from wait:true to wait:false - Add bun --hot for dev backend hot reload - Remove --force from pnpm install in dev Dockerfile for faster startup - Add env_file support to compose.dev.yaml for automatic credential loading - Add mock frontend compose config and dev panel - Update CLAUDE.md with wait:true policy and dev environment setup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * WIP: async action fixes and interest manager Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation - Fix org display name using GitHub description instead of name field - Fix createWorkbenchSession hanging when sandbox is provisioning - Fix auto-session creation retry storm on errors - Fix task creation using wrong repo due to React state race conditions - Remove Bun hot-reload from backend Dockerfile (causes port drift) - Add GitHub sync/install status to dev panel Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
34 KiB
Realtime Interest Manager — Implementation Spec
Overview
Replace the current polling + empty-notification + full-refetch architecture with a push-based realtime system. The client subscribes to topics, receives the initial state, and then receives full replacement payloads for changed entities over WebSocket. No polling. No re-fetching.
This spec covers three layers: backend (materialized state + broadcast), client library (interest manager), and frontend (hook consumption). Comment architecture-related code throughout so new contributors can understand the data flow from comments alone.
1. Data Model: What Changes
1.1 Split WorkbenchTask into summary and detail types
File: packages/shared/src/workbench.ts
Currently WorkbenchTask is a single flat type carrying everything (sidebar fields + transcripts + diffs + file tree). Split it:
/** Sidebar-level task data. Materialized in the workspace actor's SQLite. */
export interface WorkbenchTaskSummary {
id: string;
repoId: string;
title: string;
status: WorkbenchTaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkbenchSessionSummary[];
}
/** Session metadata without transcript content. */
export interface WorkbenchSessionSummary {
id: string;
sessionId: string | null;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
created: boolean;
}
/** Repo-level summary for workspace sidebar. */
export interface WorkbenchRepoSummary {
id: string;
label: string;
/** Aggregated branch/task overview state (replaces getRepoOverview polling). */
taskCount: number;
latestActivityMs: number;
}
/** Full task detail — only fetched when viewing a specific task. */
export interface WorkbenchTaskDetail {
id: string;
repoId: string;
title: string;
status: WorkbenchTaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
sessionsSummary: WorkbenchSessionSummary[];
fileChanges: WorkbenchFileChange[];
diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[];
minutesUsed: number;
/** Sandbox info for this task. */
sandboxes: WorkbenchSandboxSummary[];
activeSandboxId: string | null;
}
export interface WorkbenchSandboxSummary {
providerId: string;
sandboxId: string;
cwd: string | null;
}
/** Full session content — only fetched when viewing a specific session tab. */
export interface WorkbenchSessionDetail {
sessionId: string;
tabId: string;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
draft: WorkbenchComposerDraft;
transcript: WorkbenchTranscriptEvent[];
}
/** Workspace-level snapshot — initial fetch for the workspace topic. */
export interface WorkspaceSummarySnapshot {
workspaceId: string;
repos: WorkbenchRepoSummary[];
taskSummaries: WorkbenchTaskSummary[];
}
Remove the old TaskWorkbenchSnapshot type and WorkbenchTask type once migration is complete.
1.2 Event payload types
File: packages/shared/src/realtime-events.ts (new file)
Each event carries the full new state of the changed entity — not a patch, not an empty notification.
/** Workspace-level events broadcast by the workspace actor. */
export type WorkspaceEvent =
| { type: "taskSummaryUpdated"; taskSummary: WorkbenchTaskSummary }
| { type: "taskRemoved"; taskId: string }
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
| { type: "repoRemoved"; repoId: string };
/** Task-level events broadcast by the task actor. */
export type TaskEvent =
| { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
/** Session-level events broadcast by the task actor, filtered by sessionId on the client. */
export type SessionEvent =
| { type: "sessionUpdated"; session: WorkbenchSessionDetail };
/** App-level events broadcast by the app workspace actor. */
export type AppEvent =
| { type: "appUpdated"; snapshot: FoundryAppSnapshot };
/** Sandbox process events broadcast by the sandbox instance actor. */
export type SandboxProcessesEvent =
| { type: "processesUpdated"; processes: SandboxProcessRecord[] };
2. Backend: Materialized State + Broadcasts
2.1 Workspace actor — materialized sidebar state
Files:
packages/backend/src/actors/workspace/db/schema.ts— add tablespackages/backend/src/actors/workspace/actions.ts— replacebuildWorkbenchSnapshot, add delta handlers
Add to workspace actor SQLite schema:
export const taskSummaries = sqliteTable("task_summaries", {
taskId: text("task_id").primaryKey(),
repoId: text("repo_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull(), // WorkbenchTaskStatus
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"), // JSON-serialized WorkbenchPullRequestSummary | null
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), // JSON array of WorkbenchSessionSummary
});
New workspace actions:
/**
* Called by task actors when their summary-level state changes.
* Upserts the task summary row and broadcasts the update to all connected clients.
*
* This is the core of the materialized state pattern: task actors push their
* summary changes here instead of requiring clients to fan out to every task.
*/
async applyTaskSummaryUpdate(c, input: { taskSummary: WorkbenchTaskSummary }) {
// Upsert into taskSummaries table
await c.db.insert(taskSummaries).values(toRow(input.taskSummary))
.onConflictDoUpdate({ target: taskSummaries.taskId, set: toRow(input.taskSummary) }).run();
// Broadcast to connected clients
c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary });
}
async removeTaskSummary(c, input: { taskId: string }) {
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId });
}
/**
* Initial fetch for the workspace topic.
* Reads entirely from local SQLite — no fan-out to child actors.
*/
async getWorkspaceSummary(c, input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot> {
const repoRows = await c.db.select().from(repos).orderBy(desc(repos.updatedAt)).all();
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map(toRepoSummary),
taskSummaries: taskRows.map(toTaskSummary),
};
}
Replace buildWorkbenchSnapshot (the fan-out) — keep it only as a reconcileWorkbenchState background action for recovery/rebuild.
2.2 Task actor — push summaries to workspace + broadcast detail
Files:
packages/backend/src/actors/task/workbench.ts— replacenotifyWorkbenchUpdatedcalls
Every place that currently calls notifyWorkbenchUpdated(c) (there are ~20 call sites) must instead:
- Build the current
WorkbenchTaskSummaryfrom local state. - Push it to the workspace actor:
workspace.applyTaskSummaryUpdate({ taskSummary }). - Build the current
WorkbenchTaskDetailfrom local state. - Broadcast to directly-connected clients:
c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail }). - If session state changed, also broadcast:
c.broadcast("sessionUpdated", { type: "sessionUpdated", session: buildSessionDetail(c, sessionId) }).
Add helper functions:
/**
* Builds a WorkbenchTaskSummary from local task actor state.
* This is what gets pushed to the workspace actor for sidebar materialization.
*/
function buildTaskSummary(c: any): WorkbenchTaskSummary { ... }
/**
* Builds a WorkbenchTaskDetail from local task actor state.
* This is broadcast to clients directly connected to this task.
*/
function buildTaskDetail(c: any): WorkbenchTaskDetail { ... }
/**
* Builds a WorkbenchSessionDetail for a specific session.
* Broadcast to clients subscribed to this session's updates.
*/
function buildSessionDetail(c: any, sessionId: string): WorkbenchSessionDetail { ... }
/**
* Replaces the old notifyWorkbenchUpdated pattern.
* Pushes summary to workspace actor + broadcasts detail to direct subscribers.
*/
async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }) {
// Push summary to parent workspace actor
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyTaskSummaryUpdate({ taskSummary: buildTaskSummary(c) });
// Broadcast detail to clients connected to this task
c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail: buildTaskDetail(c) });
// If a specific session changed, broadcast session detail
if (options?.sessionId) {
c.broadcast("sessionUpdated", {
type: "sessionUpdated",
session: buildSessionDetail(c, options.sessionId),
});
}
}
2.3 Task actor — new actions for initial fetch
/**
* Initial fetch for the task topic.
* Reads from local SQLite only — no cross-actor calls.
*/
async getTaskDetail(c): Promise<WorkbenchTaskDetail> { ... }
/**
* Initial fetch for the session topic.
* Returns full session content including transcript.
*/
async getSessionDetail(c, input: { sessionId: string }): Promise<WorkbenchSessionDetail> { ... }
2.4 App workspace actor
File: packages/backend/src/actors/workspace/app-shell.ts
Change c.broadcast("appUpdated", { at: Date.now(), sessionId }) to:
c.broadcast("appUpdated", { type: "appUpdated", snapshot: await buildAppSnapshot(c, sessionId) });
2.5 Sandbox instance actor
File: packages/backend/src/actors/sandbox-instance/index.ts
Change broadcastProcessesUpdated to include the process list:
function broadcastProcessesUpdated(c: any): void {
const processes = /* read from local DB */;
c.broadcast("processesUpdated", { type: "processesUpdated", processes });
}
3. Client Library: Interest Manager
3.1 Topic definitions
File: packages/client/src/interest/topics.ts (new)
/**
* Topic definitions for the interest manager.
*
* Each topic defines how to connect to an actor, fetch initial state,
* which event to listen for, and how to apply incoming events to cached state.
*
* The interest manager uses these definitions to manage WebSocket connections,
* cached state, and subscriptions for all realtime data flows.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
/** Derive a unique cache key from params. */
key: (params: TParams) => string;
/** Which broadcast event name to listen for on the actor connection. */
event: string;
/** Open a WebSocket connection to the actor. */
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
/** Fetch the initial snapshot from the actor. */
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
/** Apply an incoming event to the current cached state. Returns the new state. */
applyEvent: (current: TData, event: TEvent) => TData;
}
export interface AppTopicParams {}
export interface WorkspaceTopicParams { workspaceId: string }
export interface TaskTopicParams { workspaceId: string; repoId: string; taskId: string }
export interface SessionTopicParams { workspaceId: string; repoId: string; taskId: string; sessionId: string }
export interface SandboxProcessesTopicParams { workspaceId: string; providerId: string; sandboxId: string }
export const topicDefinitions = {
app: {
key: () => "app",
event: "appUpdated",
connect: (b, _p) => b.connectWorkspace("app"),
fetchInitial: (b, _p) => b.getAppSnapshot(),
applyEvent: (_current, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
workspace: {
key: (p) => `workspace:${p.workspaceId}`,
event: "workspaceUpdated",
connect: (b, p) => b.connectWorkspace(p.workspaceId),
fetchInitial: (b, p) => b.getWorkspaceSummary(p.workspaceId),
applyEvent: (current, event: WorkspaceEvent) => {
switch (event.type) {
case "taskSummaryUpdated":
return {
...current,
taskSummaries: upsertById(current.taskSummaries, event.taskSummary),
};
case "taskRemoved":
return {
...current,
taskSummaries: current.taskSummaries.filter(t => t.id !== event.taskId),
};
case "repoAdded":
case "repoUpdated":
return {
...current,
repos: upsertById(current.repos, event.repo),
};
case "repoRemoved":
return {
...current,
repos: current.repos.filter(r => r.id !== event.repoId),
};
}
},
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
task: {
key: (p) => `task:${p.workspaceId}:${p.taskId}`,
event: "taskUpdated",
connect: (b, p) => b.connectTask(p.workspaceId, p.repoId, p.taskId),
fetchInitial: (b, p) => b.getTaskDetail(p.workspaceId, p.repoId, p.taskId),
applyEvent: (_current, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>,
session: {
key: (p) => `session:${p.workspaceId}:${p.taskId}:${p.sessionId}`,
event: "sessionUpdated",
// Reuses the task actor connection — same actor, different event.
connect: (b, p) => b.connectTask(p.workspaceId, p.repoId, p.taskId),
fetchInitial: (b, p) => b.getSessionDetail(p.workspaceId, p.repoId, p.taskId, p.sessionId),
applyEvent: (current, event: SessionEvent) => {
// Filter: only apply if this event is for our session
if (event.session.sessionId !== current.sessionId) return current;
return event.session;
},
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>,
sandboxProcesses: {
key: (p) => `sandbox:${p.workspaceId}:${p.sandboxId}`,
event: "processesUpdated",
connect: (b, p) => b.connectSandbox(p.workspaceId, p.providerId, p.sandboxId),
fetchInitial: (b, p) => b.listSandboxProcesses(p.workspaceId, p.providerId, p.sandboxId),
applyEvent: (_current, event: SandboxProcessesEvent) => event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;
/** Derive TypeScript types from the topic registry. */
export type TopicKey = keyof typeof topicDefinitions;
export type TopicParams<K extends TopicKey> = Parameters<(typeof topicDefinitions)[K]["fetchInitial"]>[1];
export type TopicData<K extends TopicKey> = Awaited<ReturnType<(typeof topicDefinitions)[K]["fetchInitial"]>>;
3.2 Interest manager interface
File: packages/client/src/interest/manager.ts (new)
/**
* The InterestManager owns all realtime actor connections and cached state.
*
* Architecture:
* - Each topic (app, workspace, task, session, sandboxProcesses) maps to an actor + event.
* - On first subscription, the manager opens a WebSocket connection, fetches initial state,
* and listens for events. Events carry full replacement payloads for the changed entity.
* - Multiple subscribers to the same topic share one connection and one cached state.
* - When the last subscriber leaves, a 30-second grace period keeps the connection alive
* to avoid thrashing during screen navigation or React double-renders.
* - The interface is identical for mock and remote implementations.
*/
export interface InterestManager {
/**
* Subscribe to a topic. Returns an unsubscribe function.
* On first subscriber: opens connection, fetches initial state, starts listening.
* On last unsubscribe: starts 30s grace period before teardown.
*/
subscribe<K extends TopicKey>(
topicKey: K,
params: TopicParams<K>,
listener: () => void,
): () => void;
/** Get the current cached state for a topic. Returns undefined if not yet loaded. */
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
/** Get the connection/loading status for a topic. */
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
/** Get the error (if any) for a topic. */
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
/** Dispose all connections and cached state. */
dispose(): void;
}
export type TopicStatus = "loading" | "connected" | "error";
export interface TopicState<K extends TopicKey> {
data: TopicData<K> | undefined;
status: TopicStatus;
error: Error | null;
}
3.3 Remote implementation
File: packages/client/src/interest/remote-manager.ts (new)
const GRACE_PERIOD_MS = 30_000;
/**
* Remote implementation of InterestManager.
* Manages WebSocket connections to RivetKit actors via BackendClient.
*/
export class RemoteInterestManager implements InterestManager {
private entries = new Map<string, TopicEntry<any, any, any>>();
constructor(private backend: BackendClient) {}
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void {
const def = topicDefinitions[topicKey];
const cacheKey = def.key(params);
let entry = this.entries.get(cacheKey);
if (!entry) {
entry = new TopicEntry(def, this.backend, params);
this.entries.set(cacheKey, entry);
}
entry.cancelTeardown();
entry.addListener(listener);
entry.ensureStarted();
return () => {
entry!.removeListener(listener);
if (entry!.listenerCount === 0) {
entry!.scheduleTeardown(GRACE_PERIOD_MS, () => {
this.entries.delete(cacheKey);
});
}
};
}
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.data;
}
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.status ?? "loading";
}
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.error ?? null;
}
dispose(): void {
for (const entry of this.entries.values()) {
entry.dispose();
}
this.entries.clear();
}
}
/**
* Internal entry managing one topic's connection, state, and listeners.
*
* Lifecycle:
* 1. ensureStarted() — opens WebSocket, fetches initial state, subscribes to events.
* 2. Events arrive — applyEvent() updates cached state, notifies listeners.
* 3. Last listener leaves — scheduleTeardown() starts 30s timer.
* 4. Timer fires or dispose() called — closes WebSocket, drops state.
* 5. If a new subscriber arrives during grace period — cancelTeardown(), reuse connection.
*/
class TopicEntry<TData, TParams, TEvent> {
data: TData | undefined = undefined;
status: TopicStatus = "loading";
error: Error | null = null;
listenerCount = 0;
private listeners = new Set<() => void>();
private conn: ActorConn | null = null;
private unsubscribeEvent: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private started = false;
private startPromise: Promise<void> | null = null;
constructor(
private def: TopicDefinition<TData, TParams, TEvent>,
private backend: BackendClient,
private params: TParams,
) {}
addListener(listener: () => void) {
this.listeners.add(listener);
this.listenerCount = this.listeners.size;
}
removeListener(listener: () => void) {
this.listeners.delete(listener);
this.listenerCount = this.listeners.size;
}
ensureStarted() {
if (this.started || this.startPromise) return;
this.startPromise = this.start().finally(() => { this.startPromise = null; });
}
private async start() {
try {
// Open connection
this.conn = await this.def.connect(this.backend, this.params);
// Subscribe to events
this.unsubscribeEvent = this.conn.on(this.def.event, (event: TEvent) => {
if (this.data !== undefined) {
this.data = this.def.applyEvent(this.data, event);
this.notify();
}
});
// Fetch initial state
this.data = await this.def.fetchInitial(this.backend, this.params);
this.status = "connected";
this.started = true;
this.notify();
} catch (err) {
this.status = "error";
this.error = err instanceof Error ? err : new Error(String(err));
this.notify();
}
}
scheduleTeardown(ms: number, onTeardown: () => void) {
this.teardownTimer = setTimeout(() => {
this.dispose();
onTeardown();
}, ms);
}
cancelTeardown() {
if (this.teardownTimer) {
clearTimeout(this.teardownTimer);
this.teardownTimer = null;
}
}
dispose() {
this.cancelTeardown();
this.unsubscribeEvent?.();
if (this.conn) {
void (this.conn as any).dispose?.();
}
this.conn = null;
this.data = undefined;
this.status = "loading";
this.started = false;
}
private notify() {
for (const listener of [...this.listeners]) {
listener();
}
}
}
3.4 Mock implementation
File: packages/client/src/interest/mock-manager.ts (new)
Same InterestManager interface. Uses in-memory state. Topic definitions provide mock data. Mutations call applyEvent directly on the entry to simulate broadcasts. No WebSocket connections.
3.5 React hook
File: packages/client/src/interest/use-interest.ts (new)
import { useSyncExternalStore, useMemo } from "react";
/**
* Subscribe to a realtime topic. Returns the current state, loading status, and error.
*
* - Pass `null` as params to disable the subscription (conditional interest).
* - Data is cached for 30 seconds after the last subscriber leaves.
* - Multiple components subscribing to the same topic share one connection.
*
* @example
* // Subscribe to workspace sidebar data
* const workspace = useInterest("workspace", { workspaceId });
*
* // Subscribe to task detail (only when viewing a task)
* const task = useInterest("task", selectedTaskId ? { workspaceId, repoId, taskId } : null);
*
* // Subscribe to active session content
* const session = useInterest("session", activeSessionId ? { workspaceId, repoId, taskId, sessionId } : null);
*/
export function useInterest<K extends TopicKey>(
manager: InterestManager,
topicKey: K,
params: TopicParams<K> | null,
): TopicState<K> {
// Stabilize params reference to avoid unnecessary resubscriptions
const paramsKey = params ? topicDefinitions[topicKey].key(params) : null;
const subscribe = useMemo(() => {
return (listener: () => void) => {
if (!params) return () => {};
return manager.subscribe(topicKey, params, listener);
};
}, [manager, topicKey, paramsKey]);
const getSnapshot = useMemo(() => {
return (): TopicState<K> => {
if (!params) return { data: undefined, status: "loading", error: null };
return {
data: manager.getSnapshot(topicKey, params),
status: manager.getStatus(topicKey, params),
error: manager.getError(topicKey, params),
};
};
}, [manager, topicKey, paramsKey]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
3.6 BackendClient additions
File: packages/client/src/backend-client.ts
Add to the BackendClient interface:
// New connection methods (return WebSocket-based ActorConn)
connectWorkspace(workspaceId: string): Promise<ActorConn>;
connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn>;
connectSandbox(workspaceId: string, providerId: string, sandboxId: string): Promise<ActorConn>;
// New fetch methods (read from materialized state)
getWorkspaceSummary(workspaceId: string): Promise<WorkspaceSummarySnapshot>;
getTaskDetail(workspaceId: string, repoId: string, taskId: string): Promise<WorkbenchTaskDetail>;
getSessionDetail(workspaceId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail>;
Remove:
subscribeWorkbench,subscribeApp,subscribeSandboxProcesses(replaced by interest manager)getWorkbench(replaced bygetWorkspaceSummary+getTaskDetail)
4. Frontend: Hook Consumption
4.1 Provider setup
File: packages/frontend/src/lib/interest.ts (new)
import { RemoteInterestManager } from "@sandbox-agent/foundry-client";
import { backendClient } from "./backend";
export const interestManager = new RemoteInterestManager(backendClient);
Or for mock mode:
import { MockInterestManager } from "@sandbox-agent/foundry-client";
export const interestManager = new MockInterestManager();
4.2 Replace MockLayout workbench subscription
File: packages/frontend/src/components/mock-layout.tsx
Before:
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
const viewModel = useSyncExternalStore(
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
);
const tasks = viewModel.tasks ?? [];
After:
const workspace = useInterest(interestManager, "workspace", { workspaceId });
const taskSummaries = workspace.data?.taskSummaries ?? [];
const repos = workspace.data?.repos ?? [];
4.3 Replace MockLayout task detail
When a task is selected, subscribe to its detail:
const taskDetail = useInterest(interestManager, "task",
selectedTaskId ? { workspaceId, repoId: activeRepoId, taskId: selectedTaskId } : null
);
4.4 Replace session subscription
When a session tab is active:
const sessionDetail = useInterest(interestManager, "session",
activeSessionId ? { workspaceId, repoId, taskId, sessionId: activeSessionId } : null
);
4.5 Replace workspace-dashboard.tsx polling
Remove ALL useQuery with refetchInterval in this file:
tasksQuery(2.5s polling) →useInterest("workspace", ...)taskDetailQuery(2.5s polling) →useInterest("task", ...)reposQuery(10s polling) →useInterest("workspace", ...)repoOverviewQuery(5s polling) →useInterest("workspace", ...)sessionsQuery(3s polling) →useInterest("task", ...)(sessionsSummary field)eventsQuery(2.5s polling) →useInterest("session", ...)
4.6 Replace terminal-pane.tsx polling
taskQuery(2s polling) →useInterest("task", ...)processesQuery(3s polling) →useInterest("sandboxProcesses", ...)- Remove
subscribeSandboxProcessesuseEffect
4.7 Replace app client subscription
File: packages/frontend/src/lib/mock-app.ts
Before:
export function useMockAppSnapshot(): FoundryAppSnapshot {
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient));
}
After:
export function useAppSnapshot(): FoundryAppSnapshot {
const app = useInterest(interestManager, "app", {});
return app.data ?? DEFAULT_APP_SNAPSHOT;
}
4.8 Mutations
Mutations (createTask, renameTask, sendMessage, etc.) no longer need manual refetch() or refresh() calls after completion. The backend mutation triggers a broadcast, which the interest manager receives and applies automatically.
Before:
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: async (session) => {
setActiveSessionId(session.id);
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
After:
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: (session) => {
setActiveSessionId(session.id);
// No refetch needed — server broadcast updates the task and session topics automatically
},
});
5. Files to Delete / Remove
| File/Code | Reason |
|---|---|
packages/client/src/remote/workbench-client.ts |
Replaced by interest manager workspace + task topics |
packages/client/src/remote/app-client.ts |
Replaced by interest manager app topic |
packages/client/src/workbench-client.ts |
Factory for above — no longer needed |
packages/client/src/app-client.ts |
Factory for above — no longer needed |
packages/frontend/src/lib/workbench.ts |
Workbench client singleton — replaced by interest manager |
subscribeWorkbench in backend-client.ts |
Replaced by connectWorkspace + interest manager |
subscribeSandboxProcesses in backend-client.ts |
Replaced by connectSandbox + interest manager |
subscribeApp in backend-client.ts |
Replaced by connectWorkspace("app") + interest manager |
buildWorkbenchSnapshot in workspace/actions.ts |
Replaced by getWorkspaceSummary (local reads). Keep as reconcileWorkbenchState for recovery only. |
notifyWorkbenchUpdated in workspace/actions.ts |
Replaced by applyTaskSummaryUpdate + c.broadcast with payload |
notifyWorkbenchUpdated in task/workbench.ts |
Replaced by broadcastTaskUpdate helper |
TaskWorkbenchSnapshot in shared/workbench.ts |
Replaced by WorkspaceSummarySnapshot + WorkbenchTaskDetail |
WorkbenchTask in shared/workbench.ts |
Split into WorkbenchTaskSummary + WorkbenchTaskDetail |
getWorkbench action on workspace actor |
Replaced by getWorkspaceSummary |
TaskWorkbenchClient interface |
Replaced by InterestManager + useInterest hook |
All useQuery with refetchInterval in workspace-dashboard.tsx |
Replaced by useInterest |
All useQuery with refetchInterval in terminal-pane.tsx |
Replaced by useInterest |
Mock workbench client (packages/client/src/mock/workbench-client.ts) |
Replaced by MockInterestManager |
6. Migration Order
Implement in this order to keep the system working at each step:
Phase 1: Types and backend materialization
- Add new types to
packages/shared(WorkbenchTaskSummary,WorkbenchTaskDetail,WorkbenchSessionSummary,WorkbenchSessionDetail,WorkspaceSummarySnapshot, event types). - Add
taskSummariestable to workspace actor schema. - Add
applyTaskSummaryUpdate,removeTaskSummary,getWorkspaceSummaryactions to workspace actor. - Add
getTaskDetail,getSessionDetailactions to task actor. - Replace all
notifyWorkbenchUpdatedcall sites withbroadcastTaskUpdatethat pushes summary + broadcasts detail with payload. - Change app actor broadcast to include snapshot payload.
- Change sandbox actor broadcast to include process list payload.
- Add one-time reconciliation action to populate
taskSummariestable from existing task actors (run on startup or on-demand).
Phase 2: Client interest manager
- Add
InterestManagerinterface,RemoteInterestManager,MockInterestManagertopackages/client. - Add topic definitions registry.
- Add
useInteresthook. - Add
connectWorkspace,connectTask,connectSandbox,getWorkspaceSummary,getTaskDetail,getSessionDetailtoBackendClient.
Phase 3: Frontend migration
- Replace
useMockAppSnapshotwithuseInterest("app", ...). - Replace
MockLayoutworkbench subscription withuseInterest("workspace", ...). - Replace task detail view with
useInterest("task", ...)+useInterest("session", ...). - Replace
workspace-dashboard.tsxpolling queries withuseInterest. - Replace
terminal-pane.tsxpolling queries withuseInterest. - Remove manual
refetch()calls from mutations.
Phase 4: Cleanup
- Delete old files (workbench-client, app-client, old subscribe functions, old types).
- Remove
buildWorkbenchSnapshotfrom hot path (keep asreconcileWorkbenchState). - Verify
pnpm -w typecheck,pnpm -w build,pnpm -w testpass.
7. Architecture Comments
Add doc comments at these locations:
- Topic definitions — explain the materialized state pattern, why events carry full entity state instead of patches, and the relationship between topics.
broadcastTaskUpdatehelper — explain the dual-broadcast pattern (push summary to workspace + broadcast detail to direct subscribers).InterestManagerinterface — explain the grace period, deduplication, and why mock/remote share the same interface.useInteresthook — explainuseSyncExternalStoreintegration, null params for conditional interest, and how params key stabilization works.- Workspace actor
taskSummariestable — explain this is a materialized read projection maintained by task actor pushes, not a source of truth. applyTaskSummaryUpdateaction — explain this is the write path for the materialized projection, called by task actors, not by clients.getWorkspaceSummaryaction — explain this reads from local SQLite only, no fan-out, and why that's the correct pattern.
8. Testing
- Interest manager unit tests: subscribe/unsubscribe lifecycle, grace period, deduplication, event application.
- Mock implementation tests: verify same behavior as remote through shared test suite against the
InterestManagerinterface. - Backend integration: verify
applyTaskSummaryUpdatecorrectly materializes and broadcasts. - E2E: verify that a task mutation (e.g. rename) updates the sidebar in realtime without polling.