mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 23:02:04 +00:00
WIP: async action fixes and interest manager
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0185130230
commit
2022a6ec18
35 changed files with 2950 additions and 385 deletions
|
|
@ -65,6 +65,58 @@ Use `pnpm` workspaces and Turborepo.
|
|||
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
|
||||
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.
|
||||
|
||||
## Realtime Data Architecture
|
||||
|
||||
### Core pattern: fetch initial state + subscribe to deltas
|
||||
|
||||
All client data flows follow the same pattern:
|
||||
|
||||
1. **Connect** to the actor via WebSocket.
|
||||
2. **Fetch initial state** via an action call to get the current materialized snapshot.
|
||||
3. **Subscribe to events** on the connection. Events carry **full replacement payloads** for the changed entity (not empty notifications, not patches — the complete new state of the thing that changed).
|
||||
4. **Unsubscribe** after a 30-second grace period when interest ends (screen navigation, component unmount). The grace period prevents thrashing during screen transitions and React double-renders.
|
||||
|
||||
Do not use polling (`refetchInterval`), empty "go re-fetch" broadcast events, or full-snapshot re-fetches on every mutation. Every mutation broadcasts the new absolute state of the changed entity to connected clients.
|
||||
|
||||
### Materialized state in coordinator actors
|
||||
|
||||
- **Workspace actor** materializes sidebar-level data in its own SQLite: repo catalog, task summaries (title, status, branch, PR, updatedAt), repo summaries (overview/branch state), and session summaries (id, name, status, unread, model — no transcript). Task actors push summary changes to the workspace actor when they mutate. The workspace actor broadcasts the updated entity to connected clients. `getWorkspaceSummary` reads from local tables only — no fan-out to child actors.
|
||||
- **Task actor** materializes its own detail state (session summaries, sandbox info, diffs, file tree). `getTaskDetail` reads from the task actor's own SQLite. The task actor broadcasts updates directly to clients connected to it.
|
||||
- **Session data** lives on the task actor but is a separate subscription topic. The task topic includes `sessions_summary` (list without content). The `session` topic provides full transcript and draft state. Clients subscribe to the `session` topic for whichever session tab is active, and filter `sessionUpdated` events by session ID (ignoring events for other sessions on the same actor).
|
||||
- The expensive fan-out (querying every project/task actor) only exists as a background reconciliation/rebuild path, never on the hot read path.
|
||||
|
||||
### Interest manager
|
||||
|
||||
The interest manager (`packages/client`) is a global singleton that manages WebSocket connections, cached state, and subscriptions for all topics. It:
|
||||
|
||||
- **Deduplicates** — multiple subscribers to the same topic share one connection and one cached state.
|
||||
- **Grace period (30s)** — when the last subscriber leaves, the connection and state stay alive for 30 seconds before teardown. This keeps data warm for back-navigation and prevents thrashing.
|
||||
- **Exposes a single hook** — `useInterest(topicKey, params)` returns `{ data, status, error }`. Null params = no subscription (conditional interest).
|
||||
- **Shared harness, separate implementations** — the `InterestManager` interface is shared between mock and remote implementations. The mock implementation uses in-memory state. The remote implementation uses WebSocket connections. The API/client exposure is identical for both.
|
||||
|
||||
### Topics
|
||||
|
||||
Each topic maps to one actor connection and one event stream:
|
||||
|
||||
| Topic | Actor | Event | Data |
|
||||
|---|---|---|---|
|
||||
| `app` | Workspace `"app"` | `appUpdated` | Auth, orgs, onboarding |
|
||||
| `workspace` | Workspace `{workspaceId}` | `workspaceUpdated` | Repo catalog, task summaries, repo summaries |
|
||||
| `task` | Task `{workspaceId, repoId, taskId}` | `taskUpdated` | Session summaries, sandbox info, diffs, file tree |
|
||||
| `session` | Task `{workspaceId, repoId, taskId}` (filtered by sessionId) | `sessionUpdated` | Transcript, draft state |
|
||||
| `sandboxProcesses` | SandboxInstance | `processesUpdated` | Process list |
|
||||
|
||||
The client subscribes to `app` always, `workspace` when entering a workspace, `task` when viewing a task, and `session` when viewing a specific session tab. At most 4 actor connections at a time (app + workspace + task + sandbox if terminal is open). The `session` topic reuses the task actor connection and filters by session ID.
|
||||
|
||||
### Rules
|
||||
|
||||
- Do not add `useQuery` with `refetchInterval` for data that should be push-based.
|
||||
- Do not broadcast empty notification events. Events must carry the full new state of the changed entity.
|
||||
- Do not re-fetch full snapshots after mutations. The mutation triggers a server-side broadcast with the new entity state; the client replaces it in local state.
|
||||
- All event subscriptions go through the interest manager. Do not create ad-hoc `handle.connect()` + `conn.on()` patterns.
|
||||
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent workspace actor, which broadcasts to workspace subscribers.
|
||||
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
- Runtime is Bun-native.
|
||||
|
|
|
|||
|
|
@ -278,10 +278,12 @@ async function getSandboxAgentClient(c: any) {
|
|||
});
|
||||
}
|
||||
|
||||
function broadcastProcessesUpdated(c: any): void {
|
||||
async function broadcastProcessesUpdated(c: any): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const { processes } = await client.listProcesses();
|
||||
c.broadcast("processesUpdated", {
|
||||
sandboxId: c.state.sandboxId,
|
||||
at: Date.now(),
|
||||
type: "processesUpdated",
|
||||
processes,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -475,7 +477,7 @@ export const sandboxInstance = actor({
|
|||
async createProcess(c: any, request: ProcessCreateRequest): Promise<ProcessInfo> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const created = await client.createProcess(request);
|
||||
broadcastProcessesUpdated(c);
|
||||
await broadcastProcessesUpdated(c);
|
||||
return created;
|
||||
},
|
||||
|
||||
|
|
@ -492,21 +494,21 @@ export const sandboxInstance = actor({
|
|||
async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const stopped = await client.stopProcess(request.processId, request.query);
|
||||
broadcastProcessesUpdated(c);
|
||||
await broadcastProcessesUpdated(c);
|
||||
return stopped;
|
||||
},
|
||||
|
||||
async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const killed = await client.killProcess(request.processId, request.query);
|
||||
broadcastProcessesUpdated(c);
|
||||
await broadcastProcessesUpdated(c);
|
||||
return killed;
|
||||
},
|
||||
|
||||
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
await client.deleteProcess(request.processId);
|
||||
broadcastProcessesUpdated(c);
|
||||
await broadcastProcessesUpdated(c);
|
||||
},
|
||||
|
||||
async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import {
|
|||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getWorkbenchTask,
|
||||
getSessionDetail,
|
||||
getTaskDetail,
|
||||
getTaskSummary,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
|
|
@ -228,8 +230,16 @@ export const task = actor({
|
|||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
},
|
||||
|
||||
async getWorkbench(c) {
|
||||
return await getWorkbenchTask(c);
|
||||
async getTaskSummary(c) {
|
||||
return await getTaskSummary(c);
|
||||
},
|
||||
|
||||
async getTaskDetail(c) {
|
||||
return await getTaskDetail(c);
|
||||
},
|
||||
|
||||
async getSessionDetail(c, input: { sessionId: string }) {
|
||||
return await getSessionDetail(c, input.sessionId);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -286,11 +286,6 @@ async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
|
|||
return meta;
|
||||
}
|
||||
|
||||
async function notifyWorkbenchUpdated(c: any): Promise<void> {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
}
|
||||
|
||||
function shellFragment(parts: string[]): string {
|
||||
return parts.join(" && ");
|
||||
}
|
||||
|
|
@ -600,42 +595,60 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
return record;
|
||||
}
|
||||
|
||||
export async function getWorkbenchTask(c: any): Promise<any> {
|
||||
function buildSessionSummary(record: any, meta: any): any {
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const sessionStatus =
|
||||
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && sessionStatus !== "running") {
|
||||
thinkingSinceMs = null;
|
||||
unread = true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: meta.id,
|
||||
sessionId: derivedSandboxSessionId,
|
||||
sessionName: meta.sessionName,
|
||||
agent: agentKindForModel(meta.model),
|
||||
model: meta.model,
|
||||
status: sessionStatus,
|
||||
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created || derivedSandboxSessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionDetailFromMeta(record: any, meta: any): any {
|
||||
const summary = buildSessionSummary(record, meta);
|
||||
return {
|
||||
sessionId: meta.tabId,
|
||||
tabId: meta.tabId,
|
||||
sandboxSessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
agent: summary.agent,
|
||||
model: summary.model,
|
||||
status: summary.status,
|
||||
thinkingSinceMs: summary.thinkingSinceMs,
|
||||
unread: summary.unread,
|
||||
created: summary.created,
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
updatedAtMs: meta.draftUpdatedAtMs ?? null,
|
||||
},
|
||||
transcript: meta.transcript ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkbenchTaskSummary from local task actor state. Task actors push
|
||||
* this to the parent workspace actor so workspace sidebar reads stay local.
|
||||
*/
|
||||
export async function buildTaskSummary(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await readCachedGitState(c);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
|
||||
const tabs = [];
|
||||
|
||||
for (const meta of sessions) {
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const sessionStatus =
|
||||
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && sessionStatus !== "running") {
|
||||
thinkingSinceMs = null;
|
||||
unread = true;
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
id: meta.id,
|
||||
sessionId: derivedSandboxSessionId,
|
||||
sessionName: meta.sessionName,
|
||||
agent: agentKindForModel(meta.model),
|
||||
model: meta.model,
|
||||
status: sessionStatus,
|
||||
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created || derivedSandboxSessionId),
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
updatedAtMs: meta.draftUpdatedAtMs ?? null,
|
||||
},
|
||||
transcript: meta.transcript ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: c.state.taskId,
|
||||
|
|
@ -646,19 +659,98 @@ export async function getWorkbenchTask(c: any): Promise<any> {
|
|||
updatedAtMs: record.updatedAt,
|
||||
branch: record.branchName,
|
||||
pullRequest: await readPullRequestSummary(c, record.branchName),
|
||||
tabs,
|
||||
sessionsSummary: sessions.map((meta) => buildSessionSummary(record, meta)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkbenchTaskDetail from local task actor state for direct task
|
||||
* subscribers. This is a full replacement payload, not a patch.
|
||||
*/
|
||||
export async function buildTaskDetail(c: any): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await readCachedGitState(c);
|
||||
const sessions = await listSessionMetaRows(c);
|
||||
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
|
||||
const summary = await buildTaskSummary(c);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
task: record.task,
|
||||
agentType: record.agentType === "claude" || record.agentType === "codex" ? record.agentType : null,
|
||||
runtimeStatus: record.status,
|
||||
statusMessage: record.statusMessage ?? null,
|
||||
activeSessionId: record.activeSessionId ?? null,
|
||||
diffStat: record.diffStat ?? null,
|
||||
prUrl: record.prUrl ?? null,
|
||||
reviewStatus: record.reviewStatus ?? null,
|
||||
fileChanges: gitState.fileChanges,
|
||||
diffs: gitState.diffs,
|
||||
fileTree: gitState.fileTree,
|
||||
minutesUsed: 0,
|
||||
sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({
|
||||
providerId: sandbox.providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
cwd: sandbox.cwd ?? null,
|
||||
})),
|
||||
activeSandboxId: record.activeSandboxId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkbenchSessionDetail for a specific session tab.
|
||||
*/
|
||||
export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const meta = await readSessionMeta(c, tabId);
|
||||
if (!meta || meta.closed) {
|
||||
throw new Error(`Unknown workbench session tab: ${tabId}`);
|
||||
}
|
||||
|
||||
return buildSessionDetailFromMeta(record, meta);
|
||||
}
|
||||
|
||||
export async function getTaskSummary(c: any): Promise<any> {
|
||||
return await buildTaskSummary(c);
|
||||
}
|
||||
|
||||
export async function getTaskDetail(c: any): Promise<any> {
|
||||
return await buildTaskDetail(c);
|
||||
}
|
||||
|
||||
export async function getSessionDetail(c: any, tabId: string): Promise<any> {
|
||||
return await buildSessionDetail(c, tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the old notifyWorkbenchUpdated pattern.
|
||||
*
|
||||
* The task actor emits two kinds of updates:
|
||||
* - Push summary state up to the parent workspace actor so the sidebar
|
||||
* materialized projection stays current.
|
||||
* - Broadcast full detail/session payloads down to direct task subscribers.
|
||||
*/
|
||||
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
|
||||
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||
await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
|
||||
c.broadcast("taskUpdated", {
|
||||
type: "taskDetailUpdated",
|
||||
detail: await buildTaskDetail(c),
|
||||
});
|
||||
|
||||
if (options?.sessionId) {
|
||||
c.broadcast("sessionUpdated", {
|
||||
type: "sessionUpdated",
|
||||
session: await buildSessionDetail(c, options.sessionId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWorkbenchDerivedState(c: any): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const gitState = await collectWorkbenchGitState(c, record);
|
||||
await writeCachedGitState(c, gitState);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
||||
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> {
|
||||
|
|
@ -670,7 +762,7 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin
|
|||
|
||||
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
|
||||
await writeSessionTranscript(c, meta.tabId, transcript);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
|
||||
}
|
||||
|
||||
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
|
||||
|
|
@ -688,7 +780,7 @@ export async function renameWorkbenchTask(c: any, value: string): Promise<void>
|
|||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
c.state.title = nextTitle;
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
||||
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
|
||||
|
|
@ -739,7 +831,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
taskId: c.state.taskId,
|
||||
branchName: nextBranch,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
|
|
@ -755,7 +847,7 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
|||
sessionName: "Session 1",
|
||||
status: "ready",
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: record.activeSessionId });
|
||||
return { tabId: record.activeSessionId };
|
||||
}
|
||||
}
|
||||
|
|
@ -780,7 +872,7 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
|||
wait: false,
|
||||
},
|
||||
);
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return { tabId };
|
||||
}
|
||||
|
||||
|
|
@ -815,7 +907,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -827,7 +919,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -838,7 +930,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
status: "error",
|
||||
errorMessage: "cannot create session without a sandbox cwd",
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -873,7 +965,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
});
|
||||
}
|
||||
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
}
|
||||
|
||||
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
|
||||
|
|
@ -904,14 +996,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s
|
|||
await updateSessionMeta(c, sessionId, {
|
||||
sessionName: trimmed,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: unread ? 1 : 0,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
|
|
@ -920,14 +1012,14 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
|
|||
draftAttachmentsJson: JSON.stringify(attachments),
|
||||
draftUpdatedAt: Date.now(),
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
model,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
|
|
@ -984,7 +1076,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
|
|
@ -998,7 +1090,7 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
|
|||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
}
|
||||
|
||||
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
|
||||
|
|
@ -1063,7 +1155,7 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
|
|||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1096,7 +1188,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
|
|||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
}
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
||||
export async function markWorkbenchUnread(c: any): Promise<void> {
|
||||
|
|
@ -1108,7 +1200,7 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
|
|||
await updateSessionMeta(c, latest.tabId, {
|
||||
unread: 1,
|
||||
});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c, { sessionId: latest.tabId });
|
||||
}
|
||||
|
||||
export async function publishWorkbenchPr(c: any): Promise<void> {
|
||||
|
|
@ -1129,7 +1221,7 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
|||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
||||
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
||||
|
|
@ -1152,5 +1244,5 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
|
|||
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
|
||||
await notifyWorkbenchUpdated(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { getOrCreateWorkspace } from "../../handles.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { historyKey } from "../../keys.js";
|
||||
import { broadcastTaskUpdate } from "../workbench.js";
|
||||
|
||||
export const TASK_ROW_ID = 1;
|
||||
|
||||
|
|
@ -83,8 +83,7 @@ export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?:
|
|||
.run();
|
||||
}
|
||||
|
||||
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
await broadcastTaskUpdate(ctx);
|
||||
}
|
||||
|
||||
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
||||
|
|
@ -176,6 +175,5 @@ export async function appendHistory(ctx: any, kind: string, payload: Record<stri
|
|||
payload,
|
||||
});
|
||||
|
||||
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
|
||||
await workspace.notifyWorkbenchUpdated({});
|
||||
await broadcastTaskUpdate(ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ function debugInit(loopCtx: any, message: string, context?: Record<string, unkno
|
|||
});
|
||||
}
|
||||
|
||||
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
|
||||
}
|
||||
|
||||
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
|
|
@ -61,6 +68,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
|
||||
|
||||
try {
|
||||
await ensureTaskRuntimeCacheColumns(db);
|
||||
|
||||
await db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
|
|
|
|||
|
|
@ -4,6 +4,17 @@ import { Loop } from "rivetkit/workflow";
|
|||
import type {
|
||||
AddRepoInput,
|
||||
CreateTaskInput,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ListTasksInput,
|
||||
ProviderId,
|
||||
RepoOverview,
|
||||
RepoRecord,
|
||||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
StarSandboxAgentRepoInput,
|
||||
StarSandboxAgentRepoResult,
|
||||
SwitchResult,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
|
|
@ -14,20 +25,13 @@ import type {
|
|||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ListTasksInput,
|
||||
ProviderId,
|
||||
RepoOverview,
|
||||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
StarSandboxAgentRepoInput,
|
||||
StarSandboxAgentRepoResult,
|
||||
SwitchResult,
|
||||
WorkbenchRepoSummary,
|
||||
WorkbenchSessionSummary,
|
||||
WorkbenchTaskSummary,
|
||||
WorkspaceEvent,
|
||||
WorkspaceSummarySnapshot,
|
||||
WorkspaceUseInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
|
|
@ -35,7 +39,7 @@ import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "
|
|||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
|
||||
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
import { agentTypeForModel } from "../task/workbench.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { workspaceAppActions } from "./app-shell.js";
|
||||
|
|
@ -109,6 +113,18 @@ async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Prom
|
|||
.run();
|
||||
}
|
||||
|
||||
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
|
||||
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
|
||||
|
||||
|
|
@ -145,17 +161,55 @@ function repoLabelFromRemote(remoteUrl: string): string {
|
|||
return remoteUrl;
|
||||
}
|
||||
|
||||
async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
|
||||
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepoSummary {
|
||||
const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId);
|
||||
const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt);
|
||||
|
||||
return {
|
||||
id: repoRow.repoId,
|
||||
label: repoLabelFromRemote(repoRow.remoteUrl),
|
||||
taskCount: repoTasks.length,
|
||||
latestActivityMs,
|
||||
};
|
||||
}
|
||||
|
||||
function taskSummaryRowFromSummary(taskSummary: WorkbenchTaskSummary) {
|
||||
return {
|
||||
taskId: taskSummary.id,
|
||||
repoId: taskSummary.repoId,
|
||||
title: taskSummary.title,
|
||||
status: taskSummary.status,
|
||||
repoName: taskSummary.repoName,
|
||||
updatedAtMs: taskSummary.updatedAtMs,
|
||||
branch: taskSummary.branch,
|
||||
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
|
||||
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
|
||||
};
|
||||
}
|
||||
|
||||
function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
|
||||
return {
|
||||
id: row.taskId,
|
||||
repoId: row.repoId,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
repoName: row.repoName,
|
||||
updatedAtMs: row.updatedAtMs,
|
||||
branch: row.branch ?? null,
|
||||
pullRequest: parseJsonValue(row.pullRequestJson, null),
|
||||
sessionsSummary: parseJsonValue<WorkbenchSessionSummary[]>(row.sessionsSummaryJson, []),
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
.all();
|
||||
|
||||
const tasks: Array<any> = [];
|
||||
const projects: Array<any> = [];
|
||||
const taskRows: WorkbenchTaskSummary[] = [];
|
||||
for (const row of repoRows) {
|
||||
const projectTasks: Array<any> = [];
|
||||
try {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
|
||||
const summaries = await project.listTaskSummaries({ includeArchived: true });
|
||||
|
|
@ -163,11 +217,18 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
|
|||
try {
|
||||
await upsertTaskLookupRow(c, summary.taskId, row.repoId);
|
||||
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
|
||||
const snapshot = await task.getWorkbench({});
|
||||
tasks.push(snapshot);
|
||||
projectTasks.push(snapshot);
|
||||
const taskSummary = await task.getTaskSummary({});
|
||||
taskRows.push(taskSummary);
|
||||
await c.db
|
||||
.insert(taskSummaries)
|
||||
.values(taskSummaryRowFromSummary(taskSummary))
|
||||
.onConflictDoUpdate({
|
||||
target: taskSummaries.taskId,
|
||||
set: taskSummaryRowFromSummary(taskSummary),
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed collecting workbench task", {
|
||||
logActorWarning("workspace", "failed collecting task summary during reconciliation", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
taskId: summary.taskId,
|
||||
|
|
@ -175,17 +236,8 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (projectTasks.length > 0) {
|
||||
projects.push({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl),
|
||||
updatedAtMs: projectTasks[0]?.updatedAtMs ?? row.updatedAt,
|
||||
tasks: projectTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
|
||||
logActorWarning("workspace", "failed collecting repo during workbench reconciliation", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: row.repoId,
|
||||
error: resolveErrorMessage(error),
|
||||
|
|
@ -193,16 +245,11 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => ({
|
||||
id: row.repoId,
|
||||
label: repoLabelFromRemote(row.remoteUrl),
|
||||
})),
|
||||
projects,
|
||||
tasks,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: taskRows,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +258,41 @@ async function requireWorkbenchTask(c: any, taskId: string) {
|
|||
return getTask(c, c.state.workspaceId, repoId, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
||||
* only. Task actors push summary updates into `task_summaries`, so clients do
|
||||
* not need this action to fan out to every child actor on the hot read path.
|
||||
*/
|
||||
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||
const repoRows = await c.db
|
||||
.select({
|
||||
repoId: repos.repoId,
|
||||
remoteUrl: repos.remoteUrl,
|
||||
updatedAt: repos.updatedAt,
|
||||
})
|
||||
.from(repos)
|
||||
.orderBy(desc(repos.updatedAt))
|
||||
.all();
|
||||
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
|
||||
const summaries = taskRows.map(taskSummaryFromRow);
|
||||
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
taskSummaries: summaries,
|
||||
};
|
||||
}
|
||||
|
||||
async function broadcastRepoSummary(
|
||||
c: any,
|
||||
type: "repoAdded" | "repoUpdated",
|
||||
repoRow: { repoId: string; remoteUrl: string; updatedAt: number },
|
||||
): Promise<void> {
|
||||
const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all();
|
||||
const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow));
|
||||
c.broadcast("workspaceUpdated", { type, repo } satisfies WorkspaceEvent);
|
||||
}
|
||||
|
||||
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
|
|
@ -225,6 +307,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
|
||||
const repoId = repoIdFromRemote(remoteUrl);
|
||||
const now = Date.now();
|
||||
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get();
|
||||
|
||||
await c.db
|
||||
.insert(repos)
|
||||
|
|
@ -243,7 +326,11 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
})
|
||||
.run();
|
||||
|
||||
await workspaceActions.notifyWorkbenchUpdated(c);
|
||||
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
|
||||
repoId,
|
||||
remoteUrl,
|
||||
updatedAt: now,
|
||||
});
|
||||
return {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId,
|
||||
|
|
@ -306,7 +393,20 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
|
|||
})
|
||||
.run();
|
||||
|
||||
await workspaceActions.notifyWorkbenchUpdated(c);
|
||||
try {
|
||||
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
|
||||
await workspaceActions.applyTaskSummaryUpdate(c, {
|
||||
taskSummary: await task.getTaskSummary({}),
|
||||
});
|
||||
} catch (error) {
|
||||
logActorWarning("workspace", "failed seeding task summary after task creation", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId,
|
||||
taskId: created.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
|
|
@ -462,13 +562,37 @@ export const workspaceActions = {
|
|||
};
|
||||
},
|
||||
|
||||
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<TaskWorkbenchSnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await buildWorkbenchSnapshot(c);
|
||||
/**
|
||||
* Called by task actors when their summary-level state changes.
|
||||
* This is the write path for the local materialized projection; clients read
|
||||
* the projection via `getWorkspaceSummary`, but only task actors should push
|
||||
* rows into it.
|
||||
*/
|
||||
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkbenchTaskSummary }): Promise<void> {
|
||||
await c.db
|
||||
.insert(taskSummaries)
|
||||
.values(taskSummaryRowFromSummary(input.taskSummary))
|
||||
.onConflictDoUpdate({
|
||||
target: taskSummaries.taskId,
|
||||
set: taskSummaryRowFromSummary(input.taskSummary),
|
||||
})
|
||||
.run();
|
||||
c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async notifyWorkbenchUpdated(c: any): Promise<void> {
|
||||
c.broadcast("workbenchUpdated", { at: Date.now() });
|
||||
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
|
||||
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
|
||||
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
|
||||
},
|
||||
|
||||
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await getWorkspaceSummarySnapshot(c);
|
||||
},
|
||||
|
||||
async reconcileWorkbenchState(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
return await reconcileWorkbenchProjection(c);
|
||||
},
|
||||
|
||||
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ function encodeEligibleOrganizationIds(value: string[]): string {
|
|||
return JSON.stringify([...new Set(value)]);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function seatsIncludedForPlan(planId: FoundryBillingPlanId): number {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
|
|
@ -217,7 +221,76 @@ async function getOrganizationState(workspace: any) {
|
|||
return await workspace.getOrganizationShellState({});
|
||||
}
|
||||
|
||||
async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSnapshot> {
|
||||
async function getOrganizationStateIfInitialized(workspace: any) {
|
||||
return await workspace.getOrganizationShellStateIfInitialized({});
|
||||
}
|
||||
|
||||
async function listSnapshotOrganizations(c: any, sessionId: string, organizationIds: string[]) {
|
||||
const results = await Promise.all(
|
||||
organizationIds.map(async (organizationId) => {
|
||||
const organizationStartedAt = performance.now();
|
||||
try {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
const organizationState = await getOrganizationStateIfInitialized(workspace);
|
||||
if (!organizationState) {
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_uninitialized",
|
||||
);
|
||||
return { organizationId, snapshot: null, status: "uninitialized" as const };
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_completed",
|
||||
);
|
||||
return { organizationId, snapshot: organizationState.snapshot, status: "ok" as const };
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
if (!message.includes("Actor not found")) {
|
||||
logger.error(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
errorMessage: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"build_app_snapshot_organization_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_missing",
|
||||
);
|
||||
return { organizationId, snapshot: null, status: "missing" as const };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
organizations: results.map((result) => result.snapshot).filter((organization): organization is FoundryOrganization => organization !== null),
|
||||
uninitializedOrganizationIds: results.filter((result) => result.status === "uninitialized").map((result) => result.organizationId),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepair = true): Promise<FoundryAppSnapshot> {
|
||||
assertAppWorkspace(c);
|
||||
const startedAt = performance.now();
|
||||
const auth = getBetterAuthService();
|
||||
|
|
@ -252,53 +325,31 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
"build_app_snapshot_started",
|
||||
);
|
||||
|
||||
const organizations = (
|
||||
await Promise.all(
|
||||
eligibleOrganizationIds.map(async (organizationId) => {
|
||||
const organizationStartedAt = performance.now();
|
||||
try {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
const organizationState = await getOrganizationState(workspace);
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_completed",
|
||||
);
|
||||
return organizationState.snapshot;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("Actor not found")) {
|
||||
logger.error(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
errorMessage: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"build_app_snapshot_organization_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_missing",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((organization): organization is FoundryOrganization => organization !== null);
|
||||
let { organizations, uninitializedOrganizationIds } = await listSnapshotOrganizations(c, sessionId, eligibleOrganizationIds);
|
||||
|
||||
if (allowOrganizationRepair && uninitializedOrganizationIds.length > 0) {
|
||||
const token = await auth.getAccessTokenForSession(sessionId);
|
||||
if (token?.accessToken) {
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationIds: uninitializedOrganizationIds,
|
||||
},
|
||||
"build_app_snapshot_repairing_organizations",
|
||||
);
|
||||
await syncGithubOrganizationsInternal(c, { sessionId, accessToken: token.accessToken }, { broadcast: false });
|
||||
return await buildAppSnapshot(c, sessionId, false);
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationIds: uninitializedOrganizationIds,
|
||||
},
|
||||
"build_app_snapshot_repair_skipped_no_access_token",
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser: FoundryUser | null = user
|
||||
? {
|
||||
|
|
@ -466,6 +517,10 @@ async function safeListInstallations(accessToken: string): Promise<any[]> {
|
|||
* already returned a redirect to the browser.
|
||||
*/
|
||||
export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise<void> {
|
||||
await syncGithubOrganizationsInternal(c, input, { broadcast: true });
|
||||
}
|
||||
|
||||
async function syncGithubOrganizationsInternal(c: any, input: { sessionId: string; accessToken: string }, options: { broadcast: boolean }): Promise<void> {
|
||||
assertAppWorkspace(c);
|
||||
const auth = getBetterAuthService();
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
|
|
@ -532,7 +587,13 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string
|
|||
roleLabel: "GitHub user",
|
||||
eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds),
|
||||
});
|
||||
c.broadcast("appUpdated", { at: Date.now(), sessionId });
|
||||
if (!options.broadcast) {
|
||||
return;
|
||||
}
|
||||
c.broadcast("appUpdated", {
|
||||
type: "appUpdated",
|
||||
snapshot: await buildAppSnapshot(c, sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncGithubOrganizationRepos(c: any, input: { sessionId: string; organizationId: string }): Promise<void> {
|
||||
|
|
@ -639,6 +700,19 @@ async function listOrganizationRepoCatalog(c: any): Promise<string[]> {
|
|||
async function buildOrganizationState(c: any) {
|
||||
const startedAt = performance.now();
|
||||
const row = await requireOrganizationProfileRow(c);
|
||||
return await buildOrganizationStateFromRow(c, row, startedAt);
|
||||
}
|
||||
|
||||
async function buildOrganizationStateIfInitialized(c: any) {
|
||||
const startedAt = performance.now();
|
||||
const row = await readOrganizationProfileRow(c);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return await buildOrganizationStateFromRow(c, row, startedAt);
|
||||
}
|
||||
|
||||
async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number) {
|
||||
const repoCatalog = await listOrganizationRepoCatalog(c);
|
||||
const members = await listOrganizationMembers(c);
|
||||
const seatAssignmentEmails = await listOrganizationSeatAssignments(c);
|
||||
|
|
@ -1579,6 +1653,11 @@ export const workspaceAppActions = {
|
|||
return await buildOrganizationState(c);
|
||||
},
|
||||
|
||||
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
|
||||
assertOrganizationWorkspace(c);
|
||||
return await buildOrganizationStateIfInitialized(c);
|
||||
},
|
||||
|
||||
async updateOrganizationShellProfile(c: any, input: Pick<UpdateFoundryOrganizationProfileInput, "displayName" | "slug" | "primaryDomain">): Promise<void> {
|
||||
assertOrganizationWorkspace(c);
|
||||
const existing = await requireOrganizationProfileRow(c);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ const journal = {
|
|||
tag: "0001_auth_index_tables",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1773720000000,
|
||||
tag: "0002_task_summaries",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -150,6 +156,18 @@ CREATE TABLE IF NOT EXISTS \`auth_verification\` (
|
|||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`repo_name\` text NOT NULL,
|
||||
\`updated_at_ms\` integer NOT NULL,
|
||||
\`branch\` text,
|
||||
\`pull_request_json\` text,
|
||||
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ export const taskLookup = sqliteTable("task_lookup", {
|
|||
repoId: text("repo_id").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Materialized sidebar projection maintained by task actors.
|
||||
* The source of truth still lives on each task actor; this table exists so
|
||||
* workspace reads can stay local and avoid fan-out across child actors.
|
||||
*/
|
||||
export const taskSummaries = sqliteTable("task_summaries", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
title: text("title").notNull(),
|
||||
status: text("status").notNull(),
|
||||
repoName: text("repo_name").notNull(),
|
||||
updatedAtMs: integer("updated_at_ms").notNull(),
|
||||
branch: text("branch"),
|
||||
pullRequestJson: text("pull_request_json"),
|
||||
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
|
||||
});
|
||||
|
||||
export const organizationProfile = sqliteTable("organization_profile", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
kind: text("kind").notNull(),
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"react": "^19.1.1",
|
||||
"rivetkit": "2.1.6",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.12",
|
||||
"tsup": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import type {
|
|||
FoundryAppSnapshot,
|
||||
FoundryBillingPlanId,
|
||||
CreateTaskInput,
|
||||
AppEvent,
|
||||
SessionEvent,
|
||||
SandboxProcessesEvent,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
|
|
@ -20,6 +23,12 @@ import type {
|
|||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
TaskEvent,
|
||||
WorkbenchTaskDetail,
|
||||
WorkbenchTaskSummary,
|
||||
WorkbenchSessionDetail,
|
||||
WorkspaceEvent,
|
||||
WorkspaceSummarySnapshot,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ProviderId,
|
||||
|
|
@ -34,7 +43,7 @@ import type {
|
|||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
|
||||
import { createMockBackendClient } from "./mock/backend-client.js";
|
||||
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
import { sandboxInstanceKey, taskKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
|
||||
|
||||
|
|
@ -60,7 +69,14 @@ export interface SandboxSessionEventRecord {
|
|||
|
||||
export type SandboxProcessRecord = ProcessInfo;
|
||||
|
||||
export interface ActorConn {
|
||||
on(event: string, listener: (payload: any) => void): () => void;
|
||||
onError(listener: (error: unknown) => void): () => void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkspaceHandle {
|
||||
connect(): ActorConn;
|
||||
addRepo(input: AddRepoInput): Promise<RepoRecord>;
|
||||
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
|
||||
createTask(input: CreateTaskInput): Promise<TaskRecord>;
|
||||
|
|
@ -78,7 +94,10 @@ interface WorkspaceHandle {
|
|||
killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
|
||||
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
|
||||
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
|
||||
getWorkbench(input: { workspaceId: string }): Promise<TaskWorkbenchSnapshot>;
|
||||
getWorkspaceSummary(input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot>;
|
||||
applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise<void>;
|
||||
removeTaskSummary(input: { taskId: string }): Promise<void>;
|
||||
reconcileWorkbenchState(input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot>;
|
||||
createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
|
||||
markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise<void>;
|
||||
|
|
@ -95,7 +114,15 @@ interface WorkspaceHandle {
|
|||
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
}
|
||||
|
||||
interface TaskHandle {
|
||||
getTaskSummary(): Promise<WorkbenchTaskSummary>;
|
||||
getTaskDetail(): Promise<WorkbenchTaskDetail>;
|
||||
getSessionDetail(input: { sessionId: string }): Promise<WorkbenchSessionDetail>;
|
||||
connect(): ActorConn;
|
||||
}
|
||||
|
||||
interface SandboxInstanceHandle {
|
||||
connect(): ActorConn;
|
||||
createSession(input: {
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
|
|
@ -119,6 +146,10 @@ interface RivetClient {
|
|||
workspace: {
|
||||
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle;
|
||||
};
|
||||
task: {
|
||||
get(key?: string | string[]): TaskHandle;
|
||||
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): TaskHandle;
|
||||
};
|
||||
sandboxInstance: {
|
||||
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle;
|
||||
};
|
||||
|
|
@ -132,6 +163,9 @@ export interface BackendClientOptions {
|
|||
|
||||
export interface BackendClient {
|
||||
getAppSnapshot(): Promise<FoundryAppSnapshot>;
|
||||
connectWorkspace(workspaceId: string): Promise<ActorConn>;
|
||||
connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn>;
|
||||
connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn>;
|
||||
subscribeApp(listener: () => void): () => void;
|
||||
signInWithGithub(): Promise<void>;
|
||||
signOutApp(): Promise<FoundryAppSnapshot>;
|
||||
|
|
@ -222,6 +256,9 @@ export interface BackendClient {
|
|||
sandboxId: string,
|
||||
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
|
||||
getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
|
||||
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>;
|
||||
getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot>;
|
||||
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
|
||||
createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
|
||||
|
|
@ -337,6 +374,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
createWithInput: workspaceId,
|
||||
});
|
||||
|
||||
const task = async (workspaceId: string, repoId: string, taskId: string): Promise<TaskHandle> => client.task.get(taskKey(workspaceId, repoId, taskId));
|
||||
|
||||
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
|
||||
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
|
||||
};
|
||||
|
|
@ -400,6 +439,91 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
}
|
||||
};
|
||||
|
||||
const connectWorkspace = async (workspaceId: string): Promise<ActorConn> => {
|
||||
return (await workspace(workspaceId)).connect() as ActorConn;
|
||||
};
|
||||
|
||||
const connectTask = async (workspaceId: string, repoId: string, taskIdValue: string): Promise<ActorConn> => {
|
||||
return (await task(workspaceId, repoId, taskIdValue)).connect() as ActorConn;
|
||||
};
|
||||
|
||||
const connectSandbox = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> => {
|
||||
try {
|
||||
return (await sandboxByKey(workspaceId, providerId, sandboxId)).connect() as ActorConn;
|
||||
} catch (error) {
|
||||
if (!isActorNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId);
|
||||
if (!fallback) {
|
||||
throw error;
|
||||
}
|
||||
return fallback.connect() as ActorConn;
|
||||
}
|
||||
};
|
||||
|
||||
const getWorkbenchCompat = async (workspaceId: string): Promise<TaskWorkbenchSnapshot> => {
|
||||
const summary = await (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId });
|
||||
const tasks = await Promise.all(
|
||||
summary.taskSummaries.map(async (taskSummary) => {
|
||||
const detail = await (await task(workspaceId, taskSummary.repoId, taskSummary.id)).getTaskDetail();
|
||||
const sessionDetails = await Promise.all(
|
||||
detail.sessionsSummary.map(async (session) => {
|
||||
const full = await (await task(workspaceId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id });
|
||||
return [session.id, full] as const;
|
||||
}),
|
||||
);
|
||||
const sessionDetailsById = new Map(sessionDetails);
|
||||
return {
|
||||
id: detail.id,
|
||||
repoId: detail.repoId,
|
||||
title: detail.title,
|
||||
status: detail.status,
|
||||
repoName: detail.repoName,
|
||||
updatedAtMs: detail.updatedAtMs,
|
||||
branch: detail.branch,
|
||||
pullRequest: detail.pullRequest,
|
||||
tabs: detail.sessionsSummary.map((session) => {
|
||||
const full = sessionDetailsById.get(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
status: session.status,
|
||||
thinkingSinceMs: session.thinkingSinceMs,
|
||||
unread: session.unread,
|
||||
created: session.created,
|
||||
draft: full?.draft ?? { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: full?.transcript ?? [],
|
||||
};
|
||||
}),
|
||||
fileChanges: detail.fileChanges,
|
||||
diffs: detail.diffs,
|
||||
fileTree: detail.fileTree,
|
||||
minutesUsed: detail.minutesUsed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const projects = summary.repos
|
||||
.map((repo) => ({
|
||||
id: repo.id,
|
||||
label: repo.label,
|
||||
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), repo.latestActivityMs),
|
||||
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
}))
|
||||
.filter((repo) => repo.tasks.length > 0);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
repos: summary.repos.map((repo) => ({ id: repo.id, label: repo.label })),
|
||||
projects,
|
||||
tasks: tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
};
|
||||
};
|
||||
|
||||
const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => {
|
||||
let entry = workbenchSubscriptions.get(workspaceId);
|
||||
if (!entry) {
|
||||
|
|
@ -544,6 +668,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
|
||||
},
|
||||
|
||||
async connectWorkspace(workspaceId: string): Promise<ActorConn> {
|
||||
return await connectWorkspace(workspaceId);
|
||||
},
|
||||
|
||||
async connectTask(workspaceId: string, repoId: string, taskIdValue: string): Promise<ActorConn> {
|
||||
return await connectTask(workspaceId, repoId, taskIdValue);
|
||||
},
|
||||
|
||||
async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> {
|
||||
return await connectSandbox(workspaceId, providerId, sandboxId);
|
||||
},
|
||||
|
||||
subscribeApp(listener: () => void): () => void {
|
||||
return subscribeApp(listener);
|
||||
},
|
||||
|
|
@ -861,8 +997,20 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sandboxAgentConnection());
|
||||
},
|
||||
|
||||
async getWorkspaceSummary(workspaceId: string): Promise<WorkspaceSummarySnapshot> {
|
||||
return (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId });
|
||||
},
|
||||
|
||||
async getTaskDetail(workspaceId: string, repoId: string, taskIdValue: string): Promise<WorkbenchTaskDetail> {
|
||||
return (await task(workspaceId, repoId, taskIdValue)).getTaskDetail();
|
||||
},
|
||||
|
||||
async getSessionDetail(workspaceId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkbenchSessionDetail> {
|
||||
return (await task(workspaceId, repoId, taskIdValue)).getSessionDetail({ sessionId });
|
||||
},
|
||||
|
||||
async getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot> {
|
||||
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
|
||||
return await getWorkbenchCompat(workspaceId);
|
||||
},
|
||||
|
||||
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
export * from "./app-client.js";
|
||||
export * from "./backend-client.js";
|
||||
export * from "./interest/manager.js";
|
||||
export * from "./interest/mock-manager.js";
|
||||
export * from "./interest/remote-manager.js";
|
||||
export * from "./interest/topics.js";
|
||||
export * from "./interest/use-interest.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./mock-app.js";
|
||||
export * from "./view-model.js";
|
||||
|
|
|
|||
24
foundry/packages/client/src/interest/manager.ts
Normal file
24
foundry/packages/client/src/interest/manager.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { TopicData, TopicKey, TopicParams } from "./topics.js";
|
||||
|
||||
export type TopicStatus = "loading" | "connected" | "error";
|
||||
|
||||
export interface TopicState<K extends TopicKey> {
|
||||
data: TopicData<K> | undefined;
|
||||
status: TopicStatus;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The InterestManager owns all realtime actor connections and cached state.
|
||||
*
|
||||
* Multiple subscribers to the same topic share one connection and one cache
|
||||
* entry. After the last subscriber leaves, a short grace period keeps the
|
||||
* connection warm so navigation does not thrash actor connections.
|
||||
*/
|
||||
export interface InterestManager {
|
||||
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void;
|
||||
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
|
||||
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
|
||||
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
|
||||
dispose(): void;
|
||||
}
|
||||
12
foundry/packages/client/src/interest/mock-manager.ts
Normal file
12
foundry/packages/client/src/interest/mock-manager.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createMockBackendClient } from "../mock/backend-client.js";
|
||||
import { RemoteInterestManager } from "./remote-manager.js";
|
||||
|
||||
/**
|
||||
* Mock implementation shares the same interest-manager harness as the remote
|
||||
* path, but uses the in-memory mock backend that synthesizes actor events.
|
||||
*/
|
||||
export class MockInterestManager extends RemoteInterestManager {
|
||||
constructor() {
|
||||
super(createMockBackendClient());
|
||||
}
|
||||
}
|
||||
167
foundry/packages/client/src/interest/remote-manager.ts
Normal file
167
foundry/packages/client/src/interest/remote-manager.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { BackendClient } from "../backend-client.js";
|
||||
import type { InterestManager, TopicStatus } from "./manager.js";
|
||||
import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js";
|
||||
|
||||
const GRACE_PERIOD_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Remote implementation of InterestManager.
|
||||
* Each cache entry owns one actor connection plus one materialized snapshot.
|
||||
*/
|
||||
export class RemoteInterestManager implements InterestManager {
|
||||
private entries = new Map<string, TopicEntry<any, any, any>>();
|
||||
|
||||
constructor(private readonly backend: BackendClient) {}
|
||||
|
||||
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void {
|
||||
const definition = topicDefinitions[topicKey] as unknown as TopicDefinition<any, any, any>;
|
||||
const cacheKey = definition.key(params as any);
|
||||
let entry = this.entries.get(cacheKey);
|
||||
|
||||
if (!entry) {
|
||||
entry = new TopicEntry(definition, this.backend, params as any);
|
||||
this.entries.set(cacheKey, entry);
|
||||
}
|
||||
|
||||
entry.cancelTeardown();
|
||||
entry.addListener(listener);
|
||||
entry.ensureStarted();
|
||||
|
||||
return () => {
|
||||
const current = this.entries.get(cacheKey);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.removeListener(listener);
|
||||
if (current.listenerCount === 0) {
|
||||
current.scheduleTeardown(GRACE_PERIOD_MS, () => {
|
||||
this.entries.delete(cacheKey);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined {
|
||||
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.data as TopicData<K> | undefined;
|
||||
}
|
||||
|
||||
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus {
|
||||
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.status ?? "loading";
|
||||
}
|
||||
|
||||
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null {
|
||||
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.dispose();
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class TopicEntry<TData, TParams, TEvent> {
|
||||
data: TData | undefined;
|
||||
status: TopicStatus = "loading";
|
||||
error: Error | null = null;
|
||||
listenerCount = 0;
|
||||
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private conn: Awaited<ReturnType<TopicDefinition<TData, TParams, TEvent>["connect"]>> | null = null;
|
||||
private unsubscribeEvent: (() => void) | null = null;
|
||||
private unsubscribeError: (() => void) | null = null;
|
||||
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor(
|
||||
private readonly definition: TopicDefinition<TData, TParams, TEvent>,
|
||||
private readonly backend: BackendClient,
|
||||
private readonly params: TParams,
|
||||
) {}
|
||||
|
||||
addListener(listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
this.listenerCount = this.listeners.size;
|
||||
}
|
||||
|
||||
removeListener(listener: () => void): void {
|
||||
this.listeners.delete(listener);
|
||||
this.listenerCount = this.listeners.size;
|
||||
}
|
||||
|
||||
ensureStarted(): void {
|
||||
if (this.started || this.startPromise) {
|
||||
return;
|
||||
}
|
||||
this.startPromise = this.start().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
scheduleTeardown(ms: number, onTeardown: () => void): void {
|
||||
this.teardownTimer = setTimeout(() => {
|
||||
this.dispose();
|
||||
onTeardown();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
cancelTeardown(): void {
|
||||
if (this.teardownTimer) {
|
||||
clearTimeout(this.teardownTimer);
|
||||
this.teardownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cancelTeardown();
|
||||
this.unsubscribeEvent?.();
|
||||
this.unsubscribeError?.();
|
||||
if (this.conn) {
|
||||
void this.conn.dispose();
|
||||
}
|
||||
this.conn = null;
|
||||
this.data = undefined;
|
||||
this.status = "loading";
|
||||
this.error = null;
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
this.status = "loading";
|
||||
this.error = null;
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
this.conn = await this.definition.connect(this.backend, this.params);
|
||||
this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => {
|
||||
if (this.data === undefined) {
|
||||
return;
|
||||
}
|
||||
this.data = this.definition.applyEvent(this.data, event);
|
||||
this.notify();
|
||||
});
|
||||
this.unsubscribeError = this.conn.onError((error: unknown) => {
|
||||
this.status = "error";
|
||||
this.error = error instanceof Error ? error : new Error(String(error));
|
||||
this.notify();
|
||||
});
|
||||
this.data = await this.definition.fetchInitial(this.backend, this.params);
|
||||
this.status = "connected";
|
||||
this.started = true;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
this.status = "error";
|
||||
this.error = error instanceof Error ? error : new Error(String(error));
|
||||
this.started = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
131
foundry/packages/client/src/interest/topics.ts
Normal file
131
foundry/packages/client/src/interest/topics.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type {
|
||||
AppEvent,
|
||||
FoundryAppSnapshot,
|
||||
ProviderId,
|
||||
SandboxProcessesEvent,
|
||||
SessionEvent,
|
||||
TaskEvent,
|
||||
WorkbenchSessionDetail,
|
||||
WorkbenchTaskDetail,
|
||||
WorkspaceEvent,
|
||||
WorkspaceSummarySnapshot,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-client.js";
|
||||
|
||||
/**
|
||||
* Topic definitions for the interest manager.
|
||||
*
|
||||
* Each topic describes one actor connection plus one materialized read model.
|
||||
* Events always carry full replacement payloads for the changed entity so the
|
||||
* client can replace cached state directly instead of reconstructing patches.
|
||||
*/
|
||||
export interface TopicDefinition<TData, TParams, TEvent> {
|
||||
key: (params: TParams) => string;
|
||||
event: string;
|
||||
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
|
||||
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
|
||||
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: ProviderId;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
|
||||
const filtered = items.filter((item) => item.id !== nextItem.id);
|
||||
return [...filtered, nextItem].sort(sort);
|
||||
}
|
||||
|
||||
export const topicDefinitions = {
|
||||
app: {
|
||||
key: () => "app",
|
||||
event: "appUpdated",
|
||||
connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectWorkspace("app"),
|
||||
fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(),
|
||||
applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
|
||||
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
|
||||
|
||||
workspace: {
|
||||
key: (params: WorkspaceTopicParams) => `workspace:${params.workspaceId}`,
|
||||
event: "workspaceUpdated",
|
||||
connect: (backend: BackendClient, params: WorkspaceTopicParams) => backend.connectWorkspace(params.workspaceId),
|
||||
fetchInitial: (backend: BackendClient, params: WorkspaceTopicParams) => backend.getWorkspaceSummary(params.workspaceId),
|
||||
applyEvent: (current: WorkspaceSummarySnapshot, event: WorkspaceEvent) => {
|
||||
switch (event.type) {
|
||||
case "taskSummaryUpdated":
|
||||
return {
|
||||
...current,
|
||||
taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
};
|
||||
case "taskRemoved":
|
||||
return {
|
||||
...current,
|
||||
taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId),
|
||||
};
|
||||
case "repoAdded":
|
||||
case "repoUpdated":
|
||||
return {
|
||||
...current,
|
||||
repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||
};
|
||||
case "repoRemoved":
|
||||
return {
|
||||
...current,
|
||||
repos: current.repos.filter((repo) => repo.id !== event.repoId),
|
||||
};
|
||||
}
|
||||
},
|
||||
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
|
||||
|
||||
task: {
|
||||
key: (params: TaskTopicParams) => `task:${params.workspaceId}:${params.taskId}`,
|
||||
event: "taskUpdated",
|
||||
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId),
|
||||
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.workspaceId, params.repoId, params.taskId),
|
||||
applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail,
|
||||
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>,
|
||||
|
||||
session: {
|
||||
key: (params: SessionTopicParams) => `session:${params.workspaceId}:${params.taskId}:${params.sessionId}`,
|
||||
event: "sessionUpdated",
|
||||
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId),
|
||||
fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
|
||||
backend.getSessionDetail(params.workspaceId, params.repoId, params.taskId, params.sessionId),
|
||||
applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => {
|
||||
if (event.session.sessionId !== current.sessionId) {
|
||||
return current;
|
||||
}
|
||||
return event.session;
|
||||
},
|
||||
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>,
|
||||
|
||||
sandboxProcesses: {
|
||||
key: (params: SandboxProcessesTopicParams) => `sandbox:${params.workspaceId}:${params.providerId}:${params.sandboxId}`,
|
||||
event: "processesUpdated",
|
||||
connect: (backend: BackendClient, params: SandboxProcessesTopicParams) => backend.connectSandbox(params.workspaceId, params.providerId, params.sandboxId),
|
||||
fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) =>
|
||||
(await backend.listSandboxProcesses(params.workspaceId, params.providerId, params.sandboxId)).processes,
|
||||
applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes,
|
||||
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
|
||||
} as const;
|
||||
|
||||
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"]>>;
|
||||
56
foundry/packages/client/src/interest/use-interest.ts
Normal file
56
foundry/packages/client/src/interest/use-interest.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import type { InterestManager, TopicState } from "./manager.js";
|
||||
import { topicDefinitions, type TopicKey, type TopicParams } from "./topics.js";
|
||||
|
||||
/**
|
||||
* React bridge for the interest manager.
|
||||
*
|
||||
* `null` params disable the subscription entirely, which is how screens express
|
||||
* conditional interest in task/session/sandbox topics.
|
||||
*/
|
||||
export function useInterest<K extends TopicKey>(manager: InterestManager, topicKey: K, params: TopicParams<K> | null): TopicState<K> {
|
||||
const paramsKey = params ? (topicDefinitions[topicKey] as any).key(params) : null;
|
||||
const paramsRef = useRef<TopicParams<K> | null>(params);
|
||||
paramsRef.current = params;
|
||||
|
||||
const subscribe = useMemo(() => {
|
||||
return (listener: () => void) => {
|
||||
const currentParams = paramsRef.current;
|
||||
if (!currentParams) {
|
||||
return () => {};
|
||||
}
|
||||
return manager.subscribe(topicKey, currentParams, listener);
|
||||
};
|
||||
}, [manager, topicKey, paramsKey]);
|
||||
|
||||
const getSnapshot = useMemo(() => {
|
||||
let lastSnapshot: TopicState<K> | null = null;
|
||||
|
||||
return (): TopicState<K> => {
|
||||
const currentParams = paramsRef.current;
|
||||
const nextSnapshot: TopicState<K> = currentParams
|
||||
? {
|
||||
data: manager.getSnapshot(topicKey, currentParams),
|
||||
status: manager.getStatus(topicKey, currentParams),
|
||||
error: manager.getError(topicKey, currentParams),
|
||||
}
|
||||
: {
|
||||
data: undefined,
|
||||
status: "loading",
|
||||
error: null,
|
||||
};
|
||||
|
||||
// `useSyncExternalStore` requires referentially-stable snapshots when the
|
||||
// underlying store has not changed. Reuse the previous object whenever
|
||||
// the topic data/status/error triplet is unchanged.
|
||||
if (lastSnapshot && lastSnapshot.data === nextSnapshot.data && lastSnapshot.status === nextSnapshot.status && lastSnapshot.error === nextSnapshot.error) {
|
||||
return lastSnapshot;
|
||||
}
|
||||
|
||||
lastSnapshot = nextSnapshot;
|
||||
return nextSnapshot;
|
||||
};
|
||||
}, [manager, topicKey, paramsKey]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import type {
|
||||
AddRepoInput,
|
||||
AppEvent,
|
||||
CreateTaskInput,
|
||||
FoundryAppSnapshot,
|
||||
SandboxProcessesEvent,
|
||||
SessionEvent,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
|
|
@ -16,6 +19,12 @@ import type {
|
|||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
TaskEvent,
|
||||
WorkbenchSessionDetail,
|
||||
WorkbenchTaskDetail,
|
||||
WorkbenchTaskSummary,
|
||||
WorkspaceEvent,
|
||||
WorkspaceSummarySnapshot,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ProviderId,
|
||||
|
|
@ -27,7 +36,7 @@ import type {
|
|||
SwitchResult,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
|
||||
import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
|
||||
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
|
||||
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
|
||||
|
||||
interface MockProcessRecord extends SandboxProcessRecord {
|
||||
|
|
@ -86,6 +95,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
const workbench = getSharedMockWorkbenchClient();
|
||||
const listenersBySandboxId = new Map<string, Set<() => void>>();
|
||||
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
|
||||
const connectionListeners = new Map<string, Set<(payload: any) => void>>();
|
||||
let nextPid = 4000;
|
||||
let nextProcessId = 1;
|
||||
|
||||
|
|
@ -110,11 +120,174 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
const notifySandbox = (sandboxId: string): void => {
|
||||
const listeners = listenersBySandboxId.get(sandboxId);
|
||||
if (!listeners) {
|
||||
emitSandboxProcessesUpdate(sandboxId);
|
||||
return;
|
||||
}
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
emitSandboxProcessesUpdate(sandboxId);
|
||||
};
|
||||
|
||||
const connectionChannel = (scope: string, event: string): string => `${scope}:${event}`;
|
||||
|
||||
const emitConnectionEvent = (scope: string, event: string, payload: any): void => {
|
||||
const listeners = connectionListeners.get(connectionChannel(scope, event));
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const listener of [...listeners]) {
|
||||
listener(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const createConn = (scope: string): ActorConn => ({
|
||||
on(event: string, listener: (payload: any) => void): () => void {
|
||||
const channel = connectionChannel(scope, event);
|
||||
let listeners = connectionListeners.get(channel);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
connectionListeners.set(channel, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
const current = connectionListeners.get(channel);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.delete(listener);
|
||||
if (current.size === 0) {
|
||||
connectionListeners.delete(channel);
|
||||
}
|
||||
};
|
||||
},
|
||||
onError(): () => void {
|
||||
return () => {};
|
||||
},
|
||||
async dispose(): Promise<void> {},
|
||||
});
|
||||
|
||||
const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({
|
||||
id: task.id,
|
||||
repoId: task.repoId,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
repoName: task.repoName,
|
||||
updatedAtMs: task.updatedAtMs,
|
||||
branch: task.branch,
|
||||
pullRequest: task.pullRequest,
|
||||
sessionsSummary: task.tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
sessionId: tab.sessionId,
|
||||
sessionName: tab.sessionName,
|
||||
agent: tab.agent,
|
||||
model: tab.model,
|
||||
status: tab.status,
|
||||
thinkingSinceMs: tab.thinkingSinceMs,
|
||||
unread: tab.unread,
|
||||
created: tab.created,
|
||||
})),
|
||||
});
|
||||
|
||||
const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({
|
||||
...buildTaskSummary(task),
|
||||
task: task.title,
|
||||
agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude",
|
||||
runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"),
|
||||
statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready",
|
||||
activeSessionId: task.tabs[0]?.sessionId ?? null,
|
||||
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
|
||||
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
|
||||
reviewStatus: null,
|
||||
fileChanges: task.fileChanges,
|
||||
diffs: task.diffs,
|
||||
fileTree: task.fileTree,
|
||||
minutesUsed: task.minutesUsed,
|
||||
sandboxes: [
|
||||
{
|
||||
providerId: "local",
|
||||
sandboxId: task.id,
|
||||
cwd: mockCwd(task.repoName, task.id),
|
||||
},
|
||||
],
|
||||
activeSandboxId: task.id,
|
||||
});
|
||||
|
||||
const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], tabId: string): WorkbenchSessionDetail => {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`Unknown mock tab ${tabId} for task ${task.id}`);
|
||||
}
|
||||
return {
|
||||
sessionId: tab.id,
|
||||
tabId: tab.id,
|
||||
sandboxSessionId: tab.sessionId,
|
||||
sessionName: tab.sessionName,
|
||||
agent: tab.agent,
|
||||
model: tab.model,
|
||||
status: tab.status,
|
||||
thinkingSinceMs: tab.thinkingSinceMs,
|
||||
unread: tab.unread,
|
||||
created: tab.created,
|
||||
draft: tab.draft,
|
||||
transcript: tab.transcript,
|
||||
};
|
||||
};
|
||||
|
||||
const buildWorkspaceSummary = (): WorkspaceSummarySnapshot => {
|
||||
const snapshot = workbench.getSnapshot();
|
||||
const taskSummaries = snapshot.tasks.map(buildTaskSummary);
|
||||
return {
|
||||
workspaceId: defaultWorkspaceId,
|
||||
repos: snapshot.repos.map((repo) => {
|
||||
const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id);
|
||||
return {
|
||||
id: repo.id,
|
||||
label: repo.label,
|
||||
taskCount: repoTasks.length,
|
||||
latestActivityMs: repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
|
||||
};
|
||||
}),
|
||||
taskSummaries,
|
||||
};
|
||||
};
|
||||
|
||||
const workspaceScope = (workspaceId: string): string => `workspace:${workspaceId}`;
|
||||
const taskScope = (workspaceId: string, repoId: string, taskId: string): string => `task:${workspaceId}:${repoId}:${taskId}`;
|
||||
const sandboxScope = (workspaceId: string, providerId: string, sandboxId: string): string => `sandbox:${workspaceId}:${providerId}:${sandboxId}`;
|
||||
|
||||
const emitWorkspaceSnapshot = (): void => {
|
||||
const summary = buildWorkspaceSummary();
|
||||
const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null;
|
||||
if (latestTask) {
|
||||
emitConnectionEvent(workspaceScope(defaultWorkspaceId), "workspaceUpdated", {
|
||||
type: "taskSummaryUpdated",
|
||||
taskSummary: latestTask,
|
||||
} satisfies WorkspaceEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const emitTaskUpdate = (taskId: string): void => {
|
||||
const task = requireTask(taskId);
|
||||
emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "taskUpdated", {
|
||||
type: "taskDetailUpdated",
|
||||
detail: buildTaskDetail(task),
|
||||
} satisfies TaskEvent);
|
||||
};
|
||||
|
||||
const emitSessionUpdate = (taskId: string, tabId: string): void => {
|
||||
const task = requireTask(taskId);
|
||||
emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "sessionUpdated", {
|
||||
type: "sessionUpdated",
|
||||
session: buildSessionDetail(task, tabId),
|
||||
} satisfies SessionEvent);
|
||||
};
|
||||
|
||||
const emitSandboxProcessesUpdate = (sandboxId: string): void => {
|
||||
emitConnectionEvent(sandboxScope(defaultWorkspaceId, "local", sandboxId), "processesUpdated", {
|
||||
type: "processesUpdated",
|
||||
processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)),
|
||||
} satisfies SandboxProcessesEvent);
|
||||
};
|
||||
|
||||
const buildTaskRecord = (taskId: string): TaskRecord => {
|
||||
|
|
@ -192,7 +365,19 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
subscribeApp(_listener: () => void): () => void {
|
||||
async connectWorkspace(workspaceId: string): Promise<ActorConn> {
|
||||
return createConn(workspaceScope(workspaceId));
|
||||
},
|
||||
|
||||
async connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn> {
|
||||
return createConn(taskScope(workspaceId, repoId, taskId));
|
||||
},
|
||||
|
||||
async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> {
|
||||
return createConn(sandboxScope(workspaceId, providerId, sandboxId));
|
||||
},
|
||||
|
||||
subscribeApp(): () => void {
|
||||
return () => {};
|
||||
},
|
||||
|
||||
|
|
@ -462,6 +647,18 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
return { endpoint: "mock://terminal-unavailable" };
|
||||
},
|
||||
|
||||
async getWorkspaceSummary(): Promise<WorkspaceSummarySnapshot> {
|
||||
return buildWorkspaceSummary();
|
||||
},
|
||||
|
||||
async getTaskDetail(_workspaceId: string, _repoId: string, taskId: string): Promise<WorkbenchTaskDetail> {
|
||||
return buildTaskDetail(requireTask(taskId));
|
||||
},
|
||||
|
||||
async getSessionDetail(_workspaceId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail> {
|
||||
return buildSessionDetail(requireTask(taskId), sessionId);
|
||||
},
|
||||
|
||||
async getWorkbench(): Promise<TaskWorkbenchSnapshot> {
|
||||
return workbench.getSnapshot();
|
||||
},
|
||||
|
|
@ -471,59 +668,99 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
|||
},
|
||||
|
||||
async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
|
||||
return await workbench.createTask(input);
|
||||
const created = await workbench.createTask(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(created.taskId);
|
||||
if (created.tabId) {
|
||||
emitSessionUpdate(created.taskId, created.tabId);
|
||||
}
|
||||
return created;
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.markTaskUnread(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await workbench.renameTask(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await workbench.renameBranch(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
|
||||
return await workbench.addTab(input);
|
||||
const created = await workbench.addTab(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, created.tabId);
|
||||
return created;
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
await workbench.renameSession(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await workbench.setSessionUnread(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await workbench.updateDraft(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
await workbench.changeModel(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
await workbench.sendMessage(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await workbench.stopAgent(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
emitSessionUpdate(input.taskId, input.tabId);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await workbench.closeTab(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.publishPr(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
|
||||
await workbench.revertFile(input);
|
||||
emitWorkspaceSnapshot();
|
||||
emitTaskUpdate(input.taskId);
|
||||
},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
|
|
|
|||
171
foundry/packages/client/test/interest-manager.test.ts
Normal file
171
foundry/packages/client/test/interest-manager.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkspaceEvent, WorkspaceSummarySnapshot } from "@sandbox-agent/foundry-shared";
|
||||
import type { ActorConn, BackendClient } from "../src/backend-client.js";
|
||||
import { RemoteInterestManager } from "../src/interest/remote-manager.js";
|
||||
|
||||
class FakeActorConn implements ActorConn {
|
||||
private readonly listeners = new Map<string, Set<(payload: any) => void>>();
|
||||
private readonly errorListeners = new Set<(error: unknown) => void>();
|
||||
disposeCount = 0;
|
||||
|
||||
on(event: string, listener: (payload: any) => void): () => void {
|
||||
let current = this.listeners.get(event);
|
||||
if (!current) {
|
||||
current = new Set();
|
||||
this.listeners.set(event, current);
|
||||
}
|
||||
current.add(listener);
|
||||
return () => {
|
||||
current?.delete(listener);
|
||||
if (current?.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onError(listener: (error: unknown) => void): () => void {
|
||||
this.errorListeners.add(listener);
|
||||
return () => {
|
||||
this.errorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
emit(event: string, payload: unknown): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(payload);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(error: unknown): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(error);
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceSnapshot(): WorkspaceSummarySnapshot {
|
||||
return {
|
||||
workspaceId: "ws-1",
|
||||
repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }],
|
||||
taskSummaries: [
|
||||
{
|
||||
id: "task-1",
|
||||
repoId: "repo-1",
|
||||
title: "Initial task",
|
||||
status: "idle",
|
||||
repoName: "repo-1",
|
||||
updatedAtMs: 10,
|
||||
branch: "main",
|
||||
pullRequest: null,
|
||||
sessionsSummary: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createBackend(conn: FakeActorConn, snapshot: WorkspaceSummarySnapshot): BackendClient {
|
||||
return {
|
||||
connectWorkspace: vi.fn(async () => conn),
|
||||
getWorkspaceSummary: vi.fn(async () => snapshot),
|
||||
} as unknown as BackendClient;
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("RemoteInterestManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shares one connection per topic key and applies incoming events", async () => {
|
||||
const conn = new FakeActorConn();
|
||||
const backend = createBackend(conn, workspaceSnapshot());
|
||||
const manager = new RemoteInterestManager(backend);
|
||||
const params = { workspaceId: "ws-1" } as const;
|
||||
const listenerA = vi.fn();
|
||||
const listenerB = vi.fn();
|
||||
|
||||
const unsubscribeA = manager.subscribe("workspace", params, listenerA);
|
||||
const unsubscribeB = manager.subscribe("workspace", params, listenerB);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(backend.connectWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getStatus("workspace", params)).toBe("connected");
|
||||
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task");
|
||||
|
||||
conn.emit("workspaceUpdated", {
|
||||
type: "taskSummaryUpdated",
|
||||
taskSummary: {
|
||||
id: "task-1",
|
||||
repoId: "repo-1",
|
||||
title: "Updated task",
|
||||
status: "running",
|
||||
repoName: "repo-1",
|
||||
updatedAtMs: 20,
|
||||
branch: "feature/live",
|
||||
pullRequest: null,
|
||||
sessionsSummary: [],
|
||||
},
|
||||
} satisfies WorkspaceEvent);
|
||||
|
||||
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task");
|
||||
expect(listenerA).toHaveBeenCalled();
|
||||
expect(listenerB).toHaveBeenCalled();
|
||||
|
||||
unsubscribeA();
|
||||
unsubscribeB();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it("keeps a topic warm during the grace period and tears it down afterwards", async () => {
|
||||
const conn = new FakeActorConn();
|
||||
const backend = createBackend(conn, workspaceSnapshot());
|
||||
const manager = new RemoteInterestManager(backend);
|
||||
const params = { workspaceId: "ws-1" } as const;
|
||||
|
||||
const unsubscribeA = manager.subscribe("workspace", params, () => {});
|
||||
await flushAsyncWork();
|
||||
unsubscribeA();
|
||||
|
||||
vi.advanceTimersByTime(29_000);
|
||||
|
||||
const unsubscribeB = manager.subscribe("workspace", params, () => {});
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(backend.connectWorkspace).toHaveBeenCalledTimes(1);
|
||||
expect(conn.disposeCount).toBe(0);
|
||||
|
||||
unsubscribeB();
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
expect(conn.disposeCount).toBe(1);
|
||||
expect(manager.getSnapshot("workspace", params)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces connection errors to subscribers", async () => {
|
||||
const conn = new FakeActorConn();
|
||||
const backend = createBackend(conn, workspaceSnapshot());
|
||||
const manager = new RemoteInterestManager(backend);
|
||||
const params = { workspaceId: "ws-1" } as const;
|
||||
|
||||
manager.subscribe("workspace", params, () => {});
|
||||
await flushAsyncWork();
|
||||
|
||||
conn.emitError(new Error("socket dropped"));
|
||||
|
||||
expect(manager.getStatus("workspace", params)).toBe("error");
|
||||
expect(manager.getError("workspace", params)?.message).toBe("socket dropped");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { type ReactNode, useEffect } from "react";
|
||||
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||
import { DevPanel } from "../components/dev-panel";
|
||||
import { MockLayout } from "../components/mock-layout";
|
||||
|
|
@ -13,8 +14,8 @@ import {
|
|||
MockSignInPage,
|
||||
} from "../components/mock-onboarding";
|
||||
import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env";
|
||||
import { interestManager } from "../lib/interest";
|
||||
import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
|
|
@ -325,7 +326,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
|
|||
}
|
||||
|
||||
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
|
||||
const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId);
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
useEffect(() => {
|
||||
setFrontendErrorContext({
|
||||
workspaceId,
|
||||
|
|
@ -333,7 +334,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
|
|||
repoId,
|
||||
});
|
||||
}, [repoId, workspaceId]);
|
||||
const activeTaskId = taskWorkbenchClient.getSnapshot().tasks.find((task) => task.repoId === repoId)?.id;
|
||||
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
|
||||
if (!activeTaskId) {
|
||||
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
||||
import { createErrorContext, type WorkbenchSessionSummary, type WorkbenchTaskDetail, type WorkbenchTaskSummary } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
|
|
@ -30,7 +31,8 @@ import {
|
|||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
import { backendClient } from "../lib/backend";
|
||||
import { interestManager } from "../lib/interest";
|
||||
|
||||
function firstAgentTabId(task: Task): string | null {
|
||||
return task.tabs[0]?.id ?? null;
|
||||
|
|
@ -65,6 +67,81 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||
}
|
||||
|
||||
function toLegacyTab(
|
||||
summary: WorkbenchSessionSummary,
|
||||
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
||||
): Task["tabs"][number] {
|
||||
return {
|
||||
id: summary.id,
|
||||
sessionId: summary.sessionId,
|
||||
sessionName: summary.sessionName,
|
||||
agent: summary.agent,
|
||||
model: summary.model,
|
||||
status: summary.status,
|
||||
thinkingSinceMs: summary.thinkingSinceMs,
|
||||
unread: summary.unread,
|
||||
created: summary.created,
|
||||
draft: sessionDetail?.draft ?? {
|
||||
text: "",
|
||||
attachments: [],
|
||||
updatedAtMs: null,
|
||||
},
|
||||
transcript: sessionDetail?.transcript ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function toLegacyTask(
|
||||
summary: WorkbenchTaskSummary,
|
||||
detail?: WorkbenchTaskDetail,
|
||||
sessionCache?: Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>,
|
||||
): Task {
|
||||
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
|
||||
return {
|
||||
id: summary.id,
|
||||
repoId: summary.repoId,
|
||||
title: detail?.title ?? summary.title,
|
||||
status: detail?.status ?? summary.status,
|
||||
repoName: detail?.repoName ?? summary.repoName,
|
||||
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
|
||||
branch: detail?.branch ?? summary.branch,
|
||||
pullRequest: detail?.pullRequest ?? summary.pullRequest,
|
||||
tabs: sessions.map((session) => toLegacyTab(session, sessionCache?.get(session.id))),
|
||||
fileChanges: detail?.fileChanges ?? [],
|
||||
diffs: detail?.diffs ?? {},
|
||||
fileTree: detail?.fileTree ?? [],
|
||||
minutesUsed: detail?.minutesUsed ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
|
||||
return repos
|
||||
.map((repo) => ({
|
||||
id: repo.id,
|
||||
label: repo.label,
|
||||
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
|
||||
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
}))
|
||||
.filter((repo) => repo.tasks.length > 0);
|
||||
}
|
||||
|
||||
interface WorkbenchActions {
|
||||
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
|
||||
markTaskUnread(input: { taskId: string }): Promise<void>;
|
||||
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
||||
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
||||
archiveTask(input: { taskId: string }): Promise<void>;
|
||||
publishPr(input: { taskId: string }): Promise<void>;
|
||||
revertFile(input: { taskId: string; path: string }): Promise<void>;
|
||||
updateDraft(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
|
||||
sendMessage(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
|
||||
stopAgent(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
setSessionUnread(input: { taskId: string; tabId: string; unread: boolean }): Promise<void>;
|
||||
renameSession(input: { taskId: string; tabId: string; title: string }): Promise<void>;
|
||||
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
||||
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
||||
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
||||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
taskWorkbenchClient,
|
||||
task,
|
||||
|
|
@ -83,7 +160,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onToggleRightSidebar,
|
||||
onNavigateToUsage,
|
||||
}: {
|
||||
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
|
||||
taskWorkbenchClient: WorkbenchActions;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
lastAgentTabId: string | null;
|
||||
|
|
@ -902,14 +979,82 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const navigate = useNavigate();
|
||||
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
|
||||
const viewModel = useSyncExternalStore(
|
||||
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
const taskWorkbenchClient = useMemo<WorkbenchActions>(
|
||||
() => ({
|
||||
createTask: (input) => backendClient.createWorkbenchTask(workspaceId, input),
|
||||
markTaskUnread: (input) => backendClient.markWorkbenchUnread(workspaceId, input),
|
||||
renameTask: (input) => backendClient.renameWorkbenchTask(workspaceId, input),
|
||||
renameBranch: (input) => backendClient.renameWorkbenchBranch(workspaceId, input),
|
||||
archiveTask: async (input) => backendClient.runAction(workspaceId, input.taskId, "archive"),
|
||||
publishPr: (input) => backendClient.publishWorkbenchPr(workspaceId, input),
|
||||
revertFile: (input) => backendClient.revertWorkbenchFile(workspaceId, input),
|
||||
updateDraft: (input) => backendClient.updateWorkbenchDraft(workspaceId, input),
|
||||
sendMessage: (input) => backendClient.sendWorkbenchMessage(workspaceId, input),
|
||||
stopAgent: (input) => backendClient.stopWorkbenchSession(workspaceId, input),
|
||||
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(workspaceId, input),
|
||||
renameSession: (input) => backendClient.renameWorkbenchSession(workspaceId, input),
|
||||
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
||||
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
||||
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
||||
}),
|
||||
[workspaceId],
|
||||
);
|
||||
const tasks = viewModel.tasks ?? [];
|
||||
const rawProjects = viewModel.projects ?? [];
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const workspaceRepos = workspaceState.data?.repos ?? [];
|
||||
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
||||
const selectedTaskSummary = useMemo(
|
||||
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
||||
[selectedTaskId, taskSummaries],
|
||||
);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
selectedTaskSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const sessionState = useInterest(
|
||||
interestManager,
|
||||
"session",
|
||||
selectedTaskSummary && selectedSessionId
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
sessionId: selectedSessionId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const tasks = useMemo(() => {
|
||||
const sessionCache = new Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>();
|
||||
if (selectedTaskSummary && taskState.data) {
|
||||
for (const session of taskState.data.sessionsSummary) {
|
||||
const cached =
|
||||
(selectedSessionId && session.id === selectedSessionId ? sessionState.data : undefined) ??
|
||||
interestManager.getSnapshot("session", {
|
||||
workspaceId,
|
||||
repoId: selectedTaskSummary.repoId,
|
||||
taskId: selectedTaskSummary.id,
|
||||
sessionId: session.id,
|
||||
});
|
||||
if (cached) {
|
||||
sessionCache.set(session.id, {
|
||||
draft: cached.draft,
|
||||
transcript: cached.transcript,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return taskSummaries.map((summary) =>
|
||||
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
||||
);
|
||||
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
||||
const appSnapshot = useMockAppSnapshot();
|
||||
const activeOrg = activeMockOrganization(appSnapshot);
|
||||
const navigateToUsage = useCallback(() => {
|
||||
|
|
@ -1084,16 +1229,16 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) {
|
||||
if (selectedNewTaskRepoId && workspaceRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackRepoId =
|
||||
activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? "");
|
||||
activeTask?.repoId && workspaceRepos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (workspaceRepos[0]?.id ?? "");
|
||||
if (fallbackRepoId !== selectedNewTaskRepoId) {
|
||||
setSelectedNewTaskRepoId(fallbackRepoId);
|
||||
}
|
||||
}, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]);
|
||||
}, [activeTask?.repoId, selectedNewTaskRepoId, workspaceRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTask) {
|
||||
|
|
@ -1366,7 +1511,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId=""
|
||||
onSelect={selectTask}
|
||||
|
|
@ -1421,22 +1566,22 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||
{viewModel.repos.length > 0
|
||||
{workspaceRepos.length > 0
|
||||
? "Start from the sidebar to create a task on the first available repo."
|
||||
: "No repos are available in this workspace yet."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createTask}
|
||||
disabled={viewModel.repos.length === 0}
|
||||
disabled={workspaceRepos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
border: 0,
|
||||
borderRadius: "999px",
|
||||
padding: "10px 18px",
|
||||
background: viewModel.repos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
|
||||
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
|
|
@ -1486,7 +1631,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
onSelect={selectTask}
|
||||
|
|
@ -1534,7 +1679,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
>
|
||||
<Sidebar
|
||||
projects={projects}
|
||||
newTaskRepos={viewModel.repos}
|
||||
newTaskRepos={workspaceRepos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
activeId={activeTask.id}
|
||||
onSelect={(id) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { SandboxProcessRecord } from "@sandbox-agent/foundry-client";
|
||||
import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { ProcessTerminal } from "@sandbox-agent/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useStyletron } from "baseui";
|
||||
|
|
@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-rea
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { backendClient } from "../../lib/backend";
|
||||
import { interestManager } from "../../lib/interest";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
workspaceId: string;
|
||||
|
|
@ -183,28 +184,31 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
[listWidth],
|
||||
);
|
||||
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["mock-layout", "task", workspaceId, taskId],
|
||||
enabled: Boolean(taskId),
|
||||
staleTime: 1_000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (query) => (query.state.data?.activeSandboxId ? false : 2_000),
|
||||
queryFn: async () => {
|
||||
if (!taskId) {
|
||||
throw new Error("Cannot load terminal state without a task.");
|
||||
}
|
||||
return await backendClient.getTask(workspaceId, taskId);
|
||||
},
|
||||
});
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const taskSummary = useMemo(
|
||||
() => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
|
||||
[taskId, workspaceState.data?.taskSummaries],
|
||||
);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
taskSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: taskSummary.repoId,
|
||||
taskId: taskSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const activeSandbox = useMemo(() => {
|
||||
const task = taskQuery.data;
|
||||
const task = taskState.data;
|
||||
if (!task?.activeSandboxId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null;
|
||||
}, [taskQuery.data]);
|
||||
}, [taskState.data]);
|
||||
|
||||
const connectionQuery = useQuery({
|
||||
queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
|
|
@ -220,30 +224,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
},
|
||||
});
|
||||
|
||||
const processesQuery = useQuery({
|
||||
queryKey: ["mock-layout", "sandbox-processes", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
|
||||
enabled: Boolean(activeSandbox?.sandboxId),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: activeSandbox?.sandboxId ? 3_000 : false,
|
||||
queryFn: async () => {
|
||||
if (!activeSandbox) {
|
||||
throw new Error("Cannot load processes without an active sandbox.");
|
||||
}
|
||||
|
||||
return await backendClient.listSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSandbox?.sandboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return backendClient.subscribeSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, () => {
|
||||
void processesQuery.refetch();
|
||||
});
|
||||
}, [activeSandbox?.providerId, activeSandbox?.sandboxId, processesQuery, workspaceId]);
|
||||
const processesState = useInterest(
|
||||
interestManager,
|
||||
"sandboxProcesses",
|
||||
activeSandbox
|
||||
? {
|
||||
workspaceId,
|
||||
providerId: activeSandbox.providerId,
|
||||
sandboxId: activeSandbox.sandboxId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionQuery.data) {
|
||||
|
|
@ -314,7 +305,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setProcessTabs([]);
|
||||
}, [taskId]);
|
||||
|
||||
const processes = processesQuery.data?.processes ?? [];
|
||||
const processes = processesState.data ?? [];
|
||||
|
||||
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
|
||||
setProcessTabs((current) => {
|
||||
|
|
@ -360,12 +351,11 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
sandboxId: activeSandbox.sandboxId,
|
||||
request: defaultShellRequest(activeSandbox.cwd),
|
||||
});
|
||||
await processesQuery.refetch();
|
||||
openTerminalTab(created);
|
||||
} finally {
|
||||
setCreatingProcess(false);
|
||||
}
|
||||
}, [activeSandbox, openTerminalTab, processesQuery, workspaceId]);
|
||||
}, [activeSandbox, openTerminalTab, workspaceId]);
|
||||
|
||||
const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]);
|
||||
const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null;
|
||||
|
|
@ -465,9 +455,6 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
height: "100%",
|
||||
padding: "18px 16px 14px",
|
||||
}}
|
||||
onExit={() => {
|
||||
void processesQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -484,7 +471,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
);
|
||||
}
|
||||
|
||||
if (taskQuery.isLoading) {
|
||||
if (taskState.status === "loading") {
|
||||
return (
|
||||
<div className={emptyBodyClassName}>
|
||||
<div className={emptyCopyClassName}>
|
||||
|
|
@ -494,12 +481,12 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
);
|
||||
}
|
||||
|
||||
if (taskQuery.error) {
|
||||
if (taskState.error) {
|
||||
return (
|
||||
<div className={emptyBodyClassName}>
|
||||
<div className={emptyCopyClassName}>
|
||||
<strong>Could not load task state.</strong>
|
||||
<span>{taskQuery.error.message}</span>
|
||||
<span>{taskState.error.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/foundry-shared";
|
||||
import { groupTaskStatus, type SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
|
||||
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "baseui/button";
|
||||
|
|
@ -17,6 +17,7 @@ import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizon
|
|||
import { formatDiffStat } from "../features/tasks/model";
|
||||
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
|
||||
import { backendClient } from "../lib/backend";
|
||||
import { interestManager } from "../lib/interest";
|
||||
|
||||
interface WorkspaceDashboardProps {
|
||||
workspaceId: string;
|
||||
|
|
@ -96,11 +97,9 @@ const AGENT_OPTIONS: SelectItem[] = [
|
|||
{ id: "claude", label: "claude" },
|
||||
];
|
||||
|
||||
function statusKind(status: TaskSummary["status"]): StatusTagKind {
|
||||
const group = groupTaskStatus(status);
|
||||
if (group === "running") return "positive";
|
||||
if (group === "queued") return "warning";
|
||||
if (group === "error") return "negative";
|
||||
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
|
||||
if (status === "running") return "positive";
|
||||
if (status === "new") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
|
|
@ -135,26 +134,6 @@ function branchTestIdToken(value: string): string {
|
|||
return token || "branch";
|
||||
}
|
||||
|
||||
function useSessionEvents(
|
||||
task: TaskRecord | null,
|
||||
sessionId: string | null,
|
||||
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
|
||||
return useQuery({
|
||||
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
|
||||
enabled: Boolean(task?.activeSandboxId && sessionId),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!task?.activeSandboxId || !sessionId) {
|
||||
return { items: [] };
|
||||
}
|
||||
return backendClient.listSandboxSessionEvents(task.workspaceId, task.providerId, task.activeSandboxId, {
|
||||
sessionId,
|
||||
limit: 120,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function repoSummary(overview: RepoOverview | undefined): {
|
||||
total: number;
|
||||
mapped: number;
|
||||
|
|
@ -382,37 +361,26 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
});
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "tasks"],
|
||||
queryFn: async () => backendClient.listTasks(workspaceId),
|
||||
refetchInterval: 2_500,
|
||||
});
|
||||
|
||||
const taskDetailQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
|
||||
enabled: Boolean(selectedTaskId && !repoOverviewMode),
|
||||
refetchInterval: 2_500,
|
||||
queryFn: async () => {
|
||||
if (!selectedTaskId) {
|
||||
throw new Error("No task selected");
|
||||
}
|
||||
return backendClient.getTask(workspaceId, selectedTaskId);
|
||||
},
|
||||
});
|
||||
|
||||
const reposQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "repos"],
|
||||
queryFn: async () => backendClient.listRepos(workspaceId),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const repos = reposQuery.data ?? [];
|
||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||
const repos = workspaceState.data?.repos ?? [];
|
||||
const rows = workspaceState.data?.taskSummaries ?? [];
|
||||
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
|
||||
const taskState = useInterest(
|
||||
interestManager,
|
||||
"task",
|
||||
!repoOverviewMode && selectedSummary
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedSummary.repoId,
|
||||
taskId: selectedSummary.id,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const activeRepoId = selectedRepoId ?? createRepoId;
|
||||
|
||||
const repoOverviewQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
|
||||
enabled: Boolean(repoOverviewMode && activeRepoId),
|
||||
refetchInterval: 5_000,
|
||||
queryFn: async () => {
|
||||
if (!activeRepoId) {
|
||||
throw new Error("No repo selected");
|
||||
|
|
@ -427,7 +395,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
return;
|
||||
}
|
||||
if (!createRepoId && repos.length > 0) {
|
||||
setCreateRepoId(repos[0]!.repoId);
|
||||
setCreateRepoId(repos[0]!.id);
|
||||
}
|
||||
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
|
||||
|
||||
|
|
@ -439,9 +407,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
}, [newAgentType]);
|
||||
|
||||
const rows = tasksQuery.data ?? [];
|
||||
const repoGroups = useMemo(() => {
|
||||
const byRepo = new Map<string, TaskSummary[]>();
|
||||
const byRepo = new Map<string, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const bucket = byRepo.get(row.repoId);
|
||||
if (bucket) {
|
||||
|
|
@ -453,12 +420,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
|
||||
return repos
|
||||
.map((repo) => {
|
||||
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
|
||||
const tasks = [...(byRepo.get(repo.id) ?? [])].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
||||
const latestTaskAt = tasks[0]?.updatedAtMs ?? 0;
|
||||
return {
|
||||
repoId: repo.repoId,
|
||||
repoRemote: repo.remoteUrl,
|
||||
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
|
||||
repoId: repo.id,
|
||||
repoLabel: repo.label,
|
||||
latestActivityAt: Math.max(repo.latestActivityMs, latestTaskAt),
|
||||
tasks,
|
||||
};
|
||||
})
|
||||
|
|
@ -466,13 +433,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
if (a.latestActivityAt !== b.latestActivityAt) {
|
||||
return b.latestActivityAt - a.latestActivityAt;
|
||||
}
|
||||
return a.repoRemote.localeCompare(b.repoRemote);
|
||||
return a.repoLabel.localeCompare(b.repoLabel);
|
||||
});
|
||||
}, [repos, rows]);
|
||||
|
||||
const selectedSummary = useMemo(() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
|
||||
|
||||
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
|
||||
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
|
||||
|
||||
const activeSandbox = useMemo(() => {
|
||||
if (!selectedForSession) return null;
|
||||
|
|
@ -488,7 +453,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId: rows[0]!.taskId,
|
||||
taskId: rows[0]!.id,
|
||||
},
|
||||
search: { sessionId: undefined },
|
||||
replace: true,
|
||||
|
|
@ -499,35 +464,39 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
useEffect(() => {
|
||||
setActiveSessionId(null);
|
||||
setDraft("");
|
||||
}, [selectedForSession?.taskId]);
|
||||
}, [selectedForSession?.id]);
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
|
||||
enabled: Boolean(activeSandbox?.sandboxId && selectedForSession),
|
||||
refetchInterval: 3_000,
|
||||
queryFn: async () => {
|
||||
if (!activeSandbox?.sandboxId || !selectedForSession) {
|
||||
return { items: [] };
|
||||
}
|
||||
return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, {
|
||||
limit: 30,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sessionRows = sessionsQuery.data?.items ?? [];
|
||||
const sessionRows = selectedForSession?.sessionsSummary ?? [];
|
||||
const sessionSelection = useMemo(
|
||||
() =>
|
||||
resolveSessionSelection({
|
||||
explicitSessionId: activeSessionId,
|
||||
taskSessionId: selectedForSession?.activeSessionId ?? null,
|
||||
sessions: sessionRows,
|
||||
sessions: sessionRows.map((session) => ({
|
||||
id: session.id,
|
||||
agent: session.agent,
|
||||
agentSessionId: session.sessionId ?? "",
|
||||
lastConnectionId: "",
|
||||
createdAt: 0,
|
||||
status: session.status,
|
||||
})),
|
||||
}),
|
||||
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
|
||||
);
|
||||
const resolvedSessionId = sessionSelection.sessionId;
|
||||
const staleSessionId = sessionSelection.staleSessionId;
|
||||
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
|
||||
const sessionState = useInterest(
|
||||
interestManager,
|
||||
"session",
|
||||
selectedForSession && resolvedSessionId
|
||||
? {
|
||||
workspaceId,
|
||||
repoId: selectedForSession.repoId,
|
||||
taskId: selectedForSession.id,
|
||||
sessionId: resolvedSessionId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
||||
|
||||
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||
|
|
@ -546,9 +515,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
|
||||
const createSession = useMutation({
|
||||
mutationFn: async () => startSessionFromTask(),
|
||||
onSuccess: async (session) => {
|
||||
onSuccess: (session) => {
|
||||
setActiveSessionId(session.id);
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -558,7 +526,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
}
|
||||
const created = await startSessionFromTask();
|
||||
setActiveSessionId(created.id);
|
||||
await sessionsQuery.refetch();
|
||||
return created.id;
|
||||
};
|
||||
|
||||
|
|
@ -576,13 +543,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
prompt,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: () => {
|
||||
setDraft("");
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
},
|
||||
});
|
||||
|
||||
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
|
||||
const transcript = buildTranscript(sessionState.data?.transcript ?? []);
|
||||
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
|
||||
|
||||
const createTask = useMutation({
|
||||
|
|
@ -613,8 +579,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setNewBranchName("");
|
||||
setCreateOnBranch(null);
|
||||
setCreateTaskOpen(false);
|
||||
await tasksQuery.refetch();
|
||||
await repoOverviewQuery.refetch();
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
|
|
@ -641,7 +605,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setAddRepoError(null);
|
||||
setAddRepoRemote("");
|
||||
setAddRepoOpen(false);
|
||||
await reposQuery.refetch();
|
||||
setCreateRepoId(created.repoId);
|
||||
if (repoOverviewMode) {
|
||||
await navigate({
|
||||
|
|
@ -679,7 +642,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setStackActionMessage(null);
|
||||
setStackActionError(result.message);
|
||||
}
|
||||
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
|
||||
await repoOverviewQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setStackActionMessage(null);
|
||||
|
|
@ -698,7 +661,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
setCreateTaskOpen(true);
|
||||
};
|
||||
|
||||
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
|
||||
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
|
||||
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
|
||||
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
|
||||
const selectedFilterOption = useMemo(
|
||||
|
|
@ -706,7 +669,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
[overviewFilter],
|
||||
);
|
||||
const sessionOptions = useMemo(
|
||||
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
|
||||
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
|
||||
[sessionRows],
|
||||
);
|
||||
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
|
||||
|
|
@ -839,13 +802,15 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</PanelHeader>
|
||||
|
||||
<ScrollBody>
|
||||
{tasksQuery.isLoading ? (
|
||||
{workspaceState.status === "loading" ? (
|
||||
<>
|
||||
<Skeleton rows={3} height="72px" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!tasksQuery.isLoading && repoGroups.length === 0 ? <EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState> : null}
|
||||
{workspaceState.status !== "loading" && repoGroups.length === 0 ? (
|
||||
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
|
||||
) : null}
|
||||
|
||||
{repoGroups.map((group) => (
|
||||
<section
|
||||
|
|
@ -876,7 +841,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
|
||||
>
|
||||
{group.repoRemote}
|
||||
{group.repoLabel}
|
||||
</Link>
|
||||
|
||||
<div
|
||||
|
|
@ -887,14 +852,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
})}
|
||||
>
|
||||
{group.tasks
|
||||
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
|
||||
.filter((task) => task.status !== "archived" || task.id === selectedSummary?.id)
|
||||
.map((task) => {
|
||||
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
|
||||
const isActive = !repoOverviewMode && task.id === selectedSummary?.id;
|
||||
return (
|
||||
<Link
|
||||
key={task.taskId}
|
||||
key={task.id}
|
||||
to="/workspaces/$workspaceId/tasks/$taskId"
|
||||
params={{ workspaceId, taskId: task.taskId }}
|
||||
params={{ workspaceId, taskId: task.id }}
|
||||
search={{ sessionId: undefined }}
|
||||
className={css({
|
||||
display: "block",
|
||||
|
|
@ -927,7 +892,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
color="contentSecondary"
|
||||
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
|
||||
>
|
||||
{task.branchName ?? "Determining branch..."}
|
||||
{task.branch ?? "Determining branch..."}
|
||||
</ParagraphSmall>
|
||||
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
|
||||
</div>
|
||||
|
|
@ -1396,11 +1361,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
})}
|
||||
>
|
||||
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
|
||||
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
|
||||
|
||||
{transcript.length === 0 && !eventsQuery.isLoading ? (
|
||||
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
|
||||
<EmptyState testId="session-transcript-empty">
|
||||
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
|
||||
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
|
||||
? `Session failed: ${selectedForSession.statusMessage}`
|
||||
: !activeSandbox?.sandboxId
|
||||
? selectedForSession.statusMessage
|
||||
|
|
@ -1597,7 +1562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale300,
|
||||
})}
|
||||
>
|
||||
<MetaRow label="Task" value={selectedForSession.taskId} mono />
|
||||
<MetaRow label="Task" value={selectedForSession.id} mono />
|
||||
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
|
||||
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
|
||||
</div>
|
||||
|
|
@ -1615,7 +1580,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
gap: theme.sizing.scale300,
|
||||
})}
|
||||
>
|
||||
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
|
||||
<MetaRow label="Branch" value={selectedForSession.branch ?? "-"} mono />
|
||||
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
|
||||
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
|
||||
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
|
||||
|
|
@ -1641,7 +1606,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{groupTaskStatus(selectedForSession.status) === "error" ? (
|
||||
{selectedForSession.runtimeStatus === "error" ? (
|
||||
<div
|
||||
className={css({
|
||||
padding: "12px",
|
||||
|
|
|
|||
5
foundry/packages/frontend/src/lib/interest.ts
Normal file
5
foundry/packages/frontend/src/lib/interest.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { MockInterestManager, RemoteInterestManager } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
export const interestManager = frontendClientMode === "mock" ? new MockInterestManager() : new RemoteInterestManager(backendClient);
|
||||
|
|
@ -1,23 +1,100 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
createFoundryAppClient,
|
||||
useInterest,
|
||||
currentFoundryOrganization,
|
||||
currentFoundryUser,
|
||||
eligibleFoundryOrganizations,
|
||||
type FoundryAppClient,
|
||||
} from "@sandbox-agent/foundry-client";
|
||||
import type { FoundryAppSnapshot, FoundryOrganization } from "@sandbox-agent/foundry-shared";
|
||||
import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
|
||||
import { backendClient } from "./backend";
|
||||
import { interestManager } from "./interest";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session";
|
||||
|
||||
const appClient: FoundryAppClient = createFoundryAppClient({
|
||||
const EMPTY_APP_SNAPSHOT: FoundryAppSnapshot = {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
const legacyAppClient: FoundryAppClient = createFoundryAppClient({
|
||||
mode: frontendClientMode,
|
||||
backend: frontendClientMode === "remote" ? backendClient : undefined,
|
||||
});
|
||||
|
||||
const remoteAppClient: FoundryAppClient = {
|
||||
getSnapshot(): FoundryAppSnapshot {
|
||||
return interestManager.getSnapshot("app", {}) ?? EMPTY_APP_SNAPSHOT;
|
||||
},
|
||||
subscribe(listener: () => void): () => void {
|
||||
return interestManager.subscribe("app", {}, listener);
|
||||
},
|
||||
async signInWithGithub(userId?: string): Promise<void> {
|
||||
void userId;
|
||||
await backendClient.signInWithGithub();
|
||||
},
|
||||
async signOut(): Promise<void> {
|
||||
await backendClient.signOutApp();
|
||||
},
|
||||
async skipStarterRepo(): Promise<void> {
|
||||
await backendClient.skipAppStarterRepo();
|
||||
},
|
||||
async starStarterRepo(organizationId: string): Promise<void> {
|
||||
await backendClient.starAppStarterRepo(organizationId);
|
||||
},
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
await backendClient.selectAppOrganization(organizationId);
|
||||
},
|
||||
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
||||
await backendClient.updateAppOrganizationProfile(input);
|
||||
},
|
||||
async triggerGithubSync(organizationId: string): Promise<void> {
|
||||
await backendClient.triggerAppRepoImport(organizationId);
|
||||
},
|
||||
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
|
||||
await backendClient.completeAppHostedCheckout(organizationId, planId);
|
||||
},
|
||||
async openBillingPortal(organizationId: string): Promise<void> {
|
||||
await backendClient.openAppBillingPortal(organizationId);
|
||||
},
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
await backendClient.cancelAppScheduledRenewal(organizationId);
|
||||
},
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
await backendClient.resumeAppSubscription(organizationId);
|
||||
},
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
await backendClient.reconnectAppGithub(organizationId);
|
||||
},
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
await backendClient.recordAppSeatUsage(workspaceId);
|
||||
},
|
||||
};
|
||||
|
||||
const appClient: FoundryAppClient = frontendClientMode === "remote" ? remoteAppClient : legacyAppClient;
|
||||
|
||||
export function useMockAppSnapshot(): FoundryAppSnapshot {
|
||||
if (frontendClientMode === "remote") {
|
||||
const app = useInterest(interestManager, "app", {});
|
||||
if (app.status !== "loading") {
|
||||
firstSnapshotDelivered = true;
|
||||
}
|
||||
return app.data ?? EMPTY_APP_SNAPSHOT;
|
||||
}
|
||||
|
||||
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient), appClient.getSnapshot.bind(appClient));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { createTaskWorkbenchClient, type TaskWorkbenchClient } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
import { frontendClientMode } from "./env";
|
||||
|
||||
const workbenchClients = new Map<string, TaskWorkbenchClient>();
|
||||
|
||||
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
|
||||
const existing = workbenchClients.get(workspaceId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = createTaskWorkbenchClient({
|
||||
mode: frontendClientMode,
|
||||
backend: backendClient,
|
||||
workspaceId,
|
||||
});
|
||||
workbenchClients.set(workspaceId, created);
|
||||
return created;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1",
|
||||
"sandbox-agent": "workspace:*",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export * from "./app-shell.js";
|
|||
export * from "./contracts.js";
|
||||
export * from "./config.js";
|
||||
export * from "./logging.js";
|
||||
export * from "./realtime-events.js";
|
||||
export * from "./workbench.js";
|
||||
export * from "./workspace.js";
|
||||
|
|
|
|||
36
foundry/packages/shared/src/realtime-events.ts
Normal file
36
foundry/packages/shared/src/realtime-events.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { FoundryAppSnapshot } from "./app-shell.js";
|
||||
import type { WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||
|
||||
export interface SandboxProcessSnapshot {
|
||||
id: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
createdAtMs: number;
|
||||
cwd?: string | null;
|
||||
exitCode?: number | null;
|
||||
exitedAtMs?: number | null;
|
||||
interactive: boolean;
|
||||
pid?: number | null;
|
||||
status: "running" | "exited";
|
||||
tty: boolean;
|
||||
}
|
||||
|
||||
/** 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 and 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: SandboxProcessSnapshot[] };
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { AgentType, ProviderId, TaskStatus } from "./contracts.js";
|
||||
|
||||
export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived";
|
||||
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor";
|
||||
export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
||||
|
|
@ -18,7 +20,8 @@ export interface WorkbenchComposerDraft {
|
|||
updatedAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface WorkbenchAgentTab {
|
||||
/** Session metadata without transcript content. */
|
||||
export interface WorkbenchSessionSummary {
|
||||
id: string;
|
||||
sessionId: string | null;
|
||||
sessionName: string;
|
||||
|
|
@ -28,6 +31,21 @@ export interface WorkbenchAgentTab {
|
|||
thinkingSinceMs: number | null;
|
||||
unread: boolean;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
/** Full session content — only fetched when viewing a specific session tab. */
|
||||
export interface WorkbenchSessionDetail {
|
||||
/** Stable UI tab id used for the session topic key and routing. */
|
||||
sessionId: string;
|
||||
tabId: string;
|
||||
sandboxSessionId: string | null;
|
||||
sessionName: string;
|
||||
agent: WorkbenchAgentKind;
|
||||
model: WorkbenchModelId;
|
||||
status: "running" | "idle" | "error";
|
||||
thinkingSinceMs: number | null;
|
||||
unread: boolean;
|
||||
created: boolean;
|
||||
draft: WorkbenchComposerDraft;
|
||||
transcript: WorkbenchTranscriptEvent[];
|
||||
}
|
||||
|
|
@ -76,6 +94,73 @@ export interface WorkbenchPullRequestSummary {
|
|||
status: "draft" | "ready";
|
||||
}
|
||||
|
||||
export interface WorkbenchSandboxSummary {
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
/** Full task detail — only fetched when viewing a specific task. */
|
||||
export interface WorkbenchTaskDetail extends WorkbenchTaskSummary {
|
||||
/** Original task prompt/instructions shown in the detail view. */
|
||||
task: string;
|
||||
/** Agent choice used when creating new sandbox sessions for this task. */
|
||||
agentType: AgentType | null;
|
||||
/** Underlying task runtime status preserved for detail views and error handling. */
|
||||
runtimeStatus: TaskStatus;
|
||||
statusMessage: string | null;
|
||||
activeSessionId: string | null;
|
||||
diffStat: string | null;
|
||||
prUrl: string | null;
|
||||
reviewStatus: string | null;
|
||||
fileChanges: WorkbenchFileChange[];
|
||||
diffs: Record<string, string>;
|
||||
fileTree: WorkbenchFileTreeNode[];
|
||||
minutesUsed: number;
|
||||
/** Sandbox info for this task. */
|
||||
sandboxes: WorkbenchSandboxSummary[];
|
||||
activeSandboxId: string | null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Workspace-level snapshot — initial fetch for the workspace topic. */
|
||||
export interface WorkspaceSummarySnapshot {
|
||||
workspaceId: string;
|
||||
repos: WorkbenchRepoSummary[];
|
||||
taskSummaries: WorkbenchTaskSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated compatibility aliases for older mock/view-model code.
|
||||
* New code should use the summary/detail/topic-specific types above.
|
||||
*/
|
||||
export interface WorkbenchAgentTab extends WorkbenchSessionSummary {
|
||||
draft: WorkbenchComposerDraft;
|
||||
transcript: WorkbenchTranscriptEvent[];
|
||||
}
|
||||
|
||||
export interface WorkbenchTask {
|
||||
id: string;
|
||||
repoId: string;
|
||||
|
|
|
|||
919
foundry/research/realtime-interest-manager-spec.md
Normal file
919
foundry/research/realtime-interest-manager-spec.md
Normal file
|
|
@ -0,0 +1,919 @@
|
|||
# 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:
|
||||
|
||||
```typescript
|
||||
/** 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.
|
||||
|
||||
```typescript
|
||||
/** 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 tables
|
||||
- `packages/backend/src/actors/workspace/actions.ts` — replace `buildWorkbenchSnapshot`, add delta handlers
|
||||
|
||||
Add to workspace actor SQLite schema:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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` — replace `notifyWorkbenchUpdated` calls
|
||||
|
||||
Every place that currently calls `notifyWorkbenchUpdated(c)` (there are ~20 call sites) must instead:
|
||||
|
||||
1. Build the current `WorkbenchTaskSummary` from local state.
|
||||
2. Push it to the workspace actor: `workspace.applyTaskSummaryUpdate({ taskSummary })`.
|
||||
3. Build the current `WorkbenchTaskDetail` from local state.
|
||||
4. Broadcast to directly-connected clients: `c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail })`.
|
||||
5. If session state changed, also broadcast: `c.broadcast("sessionUpdated", { type: "sessionUpdated", session: buildSessionDetail(c, sessionId) })`.
|
||||
|
||||
Add helper functions:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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:
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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)
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
// 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 by `getWorkspaceSummary` + `getTaskDetail`)
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend: Hook Consumption
|
||||
|
||||
### 4.1 Provider setup
|
||||
|
||||
**File:** `packages/frontend/src/lib/interest.ts` (new)
|
||||
|
||||
```typescript
|
||||
import { RemoteInterestManager } from "@sandbox-agent/foundry-client";
|
||||
import { backendClient } from "./backend";
|
||||
|
||||
export const interestManager = new RemoteInterestManager(backendClient);
|
||||
```
|
||||
|
||||
Or for mock mode:
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
|
||||
const viewModel = useSyncExternalStore(
|
||||
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
|
||||
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
|
||||
);
|
||||
const tasks = viewModel.tasks ?? [];
|
||||
```
|
||||
|
||||
After:
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
const taskDetail = useInterest(interestManager, "task",
|
||||
selectedTaskId ? { workspaceId, repoId: activeRepoId, taskId: selectedTaskId } : null
|
||||
);
|
||||
```
|
||||
|
||||
### 4.4 Replace session subscription
|
||||
|
||||
When a session tab is active:
|
||||
|
||||
```typescript
|
||||
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 `subscribeSandboxProcesses` useEffect
|
||||
|
||||
### 4.7 Replace app client subscription
|
||||
|
||||
**File:** `packages/frontend/src/lib/mock-app.ts`
|
||||
|
||||
Before:
|
||||
```typescript
|
||||
export function useMockAppSnapshot(): FoundryAppSnapshot {
|
||||
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient));
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
```typescript
|
||||
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:
|
||||
```typescript
|
||||
const createSession = useMutation({
|
||||
mutationFn: async () => startSessionFromTask(),
|
||||
onSuccess: async (session) => {
|
||||
setActiveSessionId(session.id);
|
||||
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
After:
|
||||
```typescript
|
||||
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
|
||||
1. Add new types to `packages/shared` (`WorkbenchTaskSummary`, `WorkbenchTaskDetail`, `WorkbenchSessionSummary`, `WorkbenchSessionDetail`, `WorkspaceSummarySnapshot`, event types).
|
||||
2. Add `taskSummaries` table to workspace actor schema.
|
||||
3. Add `applyTaskSummaryUpdate`, `removeTaskSummary`, `getWorkspaceSummary` actions to workspace actor.
|
||||
4. Add `getTaskDetail`, `getSessionDetail` actions to task actor.
|
||||
5. Replace all `notifyWorkbenchUpdated` call sites with `broadcastTaskUpdate` that pushes summary + broadcasts detail with payload.
|
||||
6. Change app actor broadcast to include snapshot payload.
|
||||
7. Change sandbox actor broadcast to include process list payload.
|
||||
8. Add one-time reconciliation action to populate `taskSummaries` table from existing task actors (run on startup or on-demand).
|
||||
|
||||
### Phase 2: Client interest manager
|
||||
9. Add `InterestManager` interface, `RemoteInterestManager`, `MockInterestManager` to `packages/client`.
|
||||
10. Add topic definitions registry.
|
||||
11. Add `useInterest` hook.
|
||||
12. Add `connectWorkspace`, `connectTask`, `connectSandbox`, `getWorkspaceSummary`, `getTaskDetail`, `getSessionDetail` to `BackendClient`.
|
||||
|
||||
### Phase 3: Frontend migration
|
||||
13. Replace `useMockAppSnapshot` with `useInterest("app", ...)`.
|
||||
14. Replace `MockLayout` workbench subscription with `useInterest("workspace", ...)`.
|
||||
15. Replace task detail view with `useInterest("task", ...)` + `useInterest("session", ...)`.
|
||||
16. Replace `workspace-dashboard.tsx` polling queries with `useInterest`.
|
||||
17. Replace `terminal-pane.tsx` polling queries with `useInterest`.
|
||||
18. Remove manual `refetch()` calls from mutations.
|
||||
|
||||
### Phase 4: Cleanup
|
||||
19. Delete old files (workbench-client, app-client, old subscribe functions, old types).
|
||||
20. Remove `buildWorkbenchSnapshot` from hot path (keep as `reconcileWorkbenchState`).
|
||||
21. Verify `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` pass.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **`broadcastTaskUpdate` helper** — explain the dual-broadcast pattern (push summary to workspace + broadcast detail to direct subscribers).
|
||||
- **`InterestManager` interface** — explain the grace period, deduplication, and why mock/remote share the same interface.
|
||||
- **`useInterest` hook** — explain `useSyncExternalStore` integration, null params for conditional interest, and how params key stabilization works.
|
||||
- **Workspace actor `taskSummaries` table** — explain this is a materialized read projection maintained by task actor pushes, not a source of truth.
|
||||
- **`applyTaskSummaryUpdate` action** — explain this is the write path for the materialized projection, called by task actors, not by clients.
|
||||
- **`getWorkspaceSummary` action** — 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 `InterestManager` interface.
|
||||
- Backend integration: verify `applyTaskSummaryUpdate` correctly materializes and broadcasts.
|
||||
- E2E: verify that a task mutation (e.g. rename) updates the sidebar in realtime without polling.
|
||||
Loading…
Add table
Add a link
Reference in a new issue