diff --git a/factory/packages/backend/src/actors/handoff/workflow/index.ts b/factory/packages/backend/src/actors/handoff/workflow/index.ts index 043d173..7c89152 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/index.ts +++ b/factory/packages/backend/src/actors/handoff/workflow/index.ts @@ -10,6 +10,7 @@ import { initCreateSessionActivity, initEnsureAgentActivity, initEnsureNameActivity, + initExposeSandboxActivity, initFailedActivity, initStartSandboxInstanceActivity, initStartStatusSyncActivity, @@ -90,6 +91,10 @@ const commandHandlers: Record = { timeout: 60_000, run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent), }); + await loopCtx.step( + "init-expose-sandbox", + async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady), + ); const session = await loopCtx.step({ name: "init-create-session", timeout: 180_000, diff --git a/factory/packages/backend/src/actors/handoff/workflow/init.ts b/factory/packages/backend/src/actors/handoff/workflow/init.ts index f523c5d..c05ee86 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/init.ts +++ b/factory/packages/backend/src/actors/handoff/workflow/init.ts @@ -376,6 +376,62 @@ export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox }); } +export async function initExposeSandboxActivity( + loopCtx: any, + body: any, + sandbox: any, + sandboxInstanceReady?: { actorId?: string | null } +): Promise { + const providerId = body?.providerId ?? loopCtx.state.providerId; + const now = Date.now(); + const db = loopCtx.db; + const activeCwd = + sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" + ? ((sandbox.metadata as any).cwd as string) + : null; + const sandboxActorId = + typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 + ? sandboxInstanceReady.actorId + : null; + + await db + .insert(handoffSandboxes) + .values({ + sandboxId: sandbox.sandboxId, + providerId, + sandboxActorId, + switchTarget: sandbox.switchTarget, + cwd: activeCwd, + statusMessage: "sandbox ready", + createdAt: now, + updatedAt: now + }) + .onConflictDoUpdate({ + target: handoffSandboxes.sandboxId, + set: { + providerId, + sandboxActorId, + switchTarget: sandbox.switchTarget, + cwd: activeCwd, + statusMessage: "sandbox ready", + updatedAt: now + } + }) + .run(); + + await db + .update(handoffRuntime) + .set({ + activeSandboxId: sandbox.sandboxId, + activeSwitchTarget: sandbox.switchTarget, + activeCwd, + statusMessage: "sandbox ready", + updatedAt: now + }) + .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .run(); +} + export async function initWriteDbActivity( loopCtx: any, body: any, diff --git a/factory/packages/backend/src/actors/sandbox-instance/index.ts b/factory/packages/backend/src/actors/sandbox-instance/index.ts index f76150f..2470aff 100644 --- a/factory/packages/backend/src/actors/sandbox-instance/index.ts +++ b/factory/packages/backend/src/actors/sandbox-instance/index.ts @@ -3,7 +3,15 @@ import { eq } from "drizzle-orm"; import { actor, queue } from "rivetkit"; import { Loop, workflow } from "rivetkit/workflow"; import type { ProviderId } from "@openhandoff/shared"; -import type { SessionEvent, SessionRecord } from "sandbox-agent"; +import type { + ProcessCreateRequest, + ProcessInfo, + ProcessLogFollowQuery, + ProcessLogsResponse, + ProcessSignalQuery, + SessionEvent, + SessionRecord, +} from "sandbox-agent"; import { sandboxInstanceDb } from "./db/db.js"; import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js"; import { SandboxInstancePersistDriver } from "./persist.js"; @@ -18,6 +26,11 @@ export interface SandboxInstanceInput { sandboxId: string; } +interface SandboxAgentConnection { + endpoint: string; + token?: string; +} + const SANDBOX_ROW_ID = 1; const CREATE_SESSION_MAX_ATTEMPTS = 3; const CREATE_SESSION_RETRY_BASE_MS = 1_000; @@ -73,7 +86,7 @@ function parseMetadata(metadataJson: string): Record { } } -async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; token?: string } | null> { +async function loadPersistedAgentConfig(c: any): Promise { try { const row = await c.db .select({ metadataJson: sandboxInstanceTable.metadataJson }) @@ -95,7 +108,7 @@ async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; tok return null; } -async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { +async function loadFreshDaytonaAgentConfig(c: any): Promise { const { config, driver } = getActorRuntimeContext(); const daytona = driver.daytona.createClient({ apiUrl: config.providers.daytona.endpoint, @@ -110,7 +123,7 @@ async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string; return preview.token ? { endpoint: preview.url, token: preview.token } : { endpoint: preview.url }; } -async function loadFreshProviderAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { +async function loadFreshProviderAgentConfig(c: any): Promise { const { providers } = getActorRuntimeContext(); const provider = providers.get(c.state.providerId); return await provider.ensureSandboxAgent({ @@ -119,7 +132,7 @@ async function loadFreshProviderAgentConfig(c: any): Promise<{ endpoint: string; }); } -async function loadAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { +async function loadAgentConfig(c: any): Promise { const persisted = await loadPersistedAgentConfig(c); if (c.state.providerId === "daytona") { // Keep one stable signed preview endpoint per sandbox-instance actor. @@ -265,6 +278,13 @@ async function getSandboxAgentClient(c: any) { }); } +function broadcastProcessesUpdated(c: any): void { + c.broadcast("processesUpdated", { + sandboxId: c.state.sandboxId, + at: Date.now(), + }); +} + async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Promise { const now = Date.now(); const metadata = { @@ -446,6 +466,56 @@ export const sandboxInstance = actor({ sandboxId: input.sandboxId, }), actions: { + async sandboxAgentConnection(c: any): Promise { + return await loadAgentConfig(c); + }, + + async createProcess(c: any, request: ProcessCreateRequest): Promise { + const client = await getSandboxAgentClient(c); + const created = await client.createProcess(request); + broadcastProcessesUpdated(c); + return created; + }, + + async listProcesses(c: any): Promise<{ processes: ProcessInfo[] }> { + const client = await getSandboxAgentClient(c); + return await client.listProcesses(); + }, + + async getProcessLogs( + c: any, + request: { processId: string; query?: ProcessLogFollowQuery } + ): Promise { + const client = await getSandboxAgentClient(c); + return await client.getProcessLogs(request.processId, request.query); + }, + + async stopProcess( + c: any, + request: { processId: string; query?: ProcessSignalQuery } + ): Promise { + const client = await getSandboxAgentClient(c); + const stopped = await client.stopProcess(request.processId, request.query); + broadcastProcessesUpdated(c); + return stopped; + }, + + async killProcess( + c: any, + request: { processId: string; query?: ProcessSignalQuery } + ): Promise { + const client = await getSandboxAgentClient(c); + const killed = await client.killProcess(request.processId, request.query); + broadcastProcessesUpdated(c); + return killed; + }, + + async deleteProcess(c: any, request: { processId: string }): Promise { + const client = await getSandboxAgentClient(c); + await client.deleteProcess(request.processId); + broadcastProcessesUpdated(c); + }, + async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> { const at = Date.now(); const { config, driver } = getActorRuntimeContext(); diff --git a/factory/packages/backend/src/driver.ts b/factory/packages/backend/src/driver.ts index 27ed80f..fc1fc65 100644 --- a/factory/packages/backend/src/driver.ts +++ b/factory/packages/backend/src/driver.ts @@ -1,8 +1,28 @@ import type { BranchSnapshot } from "./integrations/git/index.js"; import type { PullRequestSnapshot } from "./integrations/github/index.js"; -import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent"; -import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js"; +import type { + SandboxSession, + SandboxAgentClientOptions, + SandboxSessionCreateRequest +} from "./integrations/sandbox-agent/client.js"; +import type { + ListEventsRequest, + ListPage, + ListPageRequest, + ProcessCreateRequest, + ProcessInfo, + ProcessLogFollowQuery, + ProcessLogsResponse, + ProcessSignalQuery, + SessionEvent, + SessionRecord, +} from "sandbox-agent"; +import type { + DaytonaClientOptions, + DaytonaCreateSandboxOptions, + DaytonaPreviewEndpoint, + DaytonaSandbox, +} from "./integrations/daytona/client.js"; import { validateRemote, ensureCloned, @@ -67,6 +87,12 @@ export interface SandboxAgentClientLike { sessionStatus(sessionId: string): Promise; listSessions(request?: ListPageRequest): Promise>; listEvents(request: ListEventsRequest): Promise>; + createProcess(request: ProcessCreateRequest): Promise; + listProcesses(): Promise<{ processes: ProcessInfo[] }>; + getProcessLogs(processId: string, query?: ProcessLogFollowQuery): Promise; + stopProcess(processId: string, query?: ProcessSignalQuery): Promise; + killProcess(processId: string, query?: ProcessSignalQuery): Promise; + deleteProcess(processId: string): Promise; sendPrompt(request: { sessionId: string; prompt: string; notification?: boolean }): Promise; cancelSession(sessionId: string): Promise; destroySession(sessionId: string): Promise; diff --git a/factory/packages/backend/src/integrations/sandbox-agent/client.ts b/factory/packages/backend/src/integrations/sandbox-agent/client.ts index 5a2ee0e..9ee44d7 100644 --- a/factory/packages/backend/src/integrations/sandbox-agent/client.ts +++ b/factory/packages/backend/src/integrations/sandbox-agent/client.ts @@ -1,5 +1,17 @@ import type { AgentType } from "@openhandoff/shared"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent"; +import type { + ListEventsRequest, + ListPage, + ListPageRequest, + ProcessCreateRequest, + ProcessInfo, + ProcessLogFollowQuery, + ProcessLogsResponse, + ProcessSignalQuery, + SessionEvent, + SessionPersistDriver, + SessionRecord +} from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent"; export type AgentId = AgentType | "opencode"; @@ -199,6 +211,39 @@ export class SandboxAgentClient { return sdk.getEvents(request); } + async createProcess(request: ProcessCreateRequest): Promise { + const sdk = await this.sdk(); + return await sdk.createProcess(request); + } + + async listProcesses(): Promise<{ processes: ProcessInfo[] }> { + const sdk = await this.sdk(); + return await sdk.listProcesses(); + } + + async getProcessLogs( + processId: string, + query: ProcessLogFollowQuery = {} + ): Promise { + const sdk = await this.sdk(); + return await sdk.getProcessLogs(processId, query); + } + + async stopProcess(processId: string, query?: ProcessSignalQuery): Promise { + const sdk = await this.sdk(); + return await sdk.stopProcess(processId, query); + } + + async killProcess(processId: string, query?: ProcessSignalQuery): Promise { + const sdk = await this.sdk(); + return await sdk.killProcess(processId, query); + } + + async deleteProcess(processId: string): Promise { + const sdk = await this.sdk(); + await sdk.deleteProcess(processId); + } + async sendPrompt(request: SandboxSessionPromptRequest): Promise { const sdk = await this.sdk(); const existing = await sdk.getSession(request.sessionId); diff --git a/factory/packages/backend/test/helpers/test-driver.ts b/factory/packages/backend/test/helpers/test-driver.ts index d31812d..97ef444 100644 --- a/factory/packages/backend/test/helpers/test-driver.ts +++ b/factory/packages/backend/test/helpers/test-driver.ts @@ -9,7 +9,15 @@ import type { SandboxAgentClientLike, TmuxDriver, } from "../../src/driver.js"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent"; +import type { + ListEventsRequest, + ListPage, + ListPageRequest, + ProcessInfo, + ProcessLogsResponse, + SessionEvent, + SessionRecord, +} from "sandbox-agent"; export function createTestDriver(overrides?: Partial): BackendDriver { return { @@ -70,7 +78,27 @@ export function createTestSandboxAgentDriver(overrides?: Partial): SandboxAgentClientLike { +export function createTestSandboxAgentClient( + overrides?: Partial +): SandboxAgentClientLike { + const defaultProcess: ProcessInfo = { + id: "process-1", + command: "bash", + args: ["-lc", "echo test"], + createdAtMs: Date.now(), + cwd: "/workspace", + exitCode: null, + exitedAtMs: null, + interactive: true, + pid: 123, + status: "running", + tty: true, + }; + const defaultLogs: ProcessLogsResponse = { + processId: defaultProcess.id, + stream: "combined", + entries: [], + }; return { createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }), sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }), @@ -82,6 +110,12 @@ export function createTestSandboxAgentClient(overrides?: Partial defaultProcess, + listProcesses: async () => ({ processes: [defaultProcess] }), + getProcessLogs: async () => defaultLogs, + stopProcess: async () => ({ ...defaultProcess, status: "exited", exitCode: 0, exitedAtMs: Date.now() }), + killProcess: async () => ({ ...defaultProcess, status: "exited", exitCode: 137, exitedAtMs: Date.now() }), + deleteProcess: async () => {}, sendPrompt: async (_request) => {}, cancelSession: async (_sessionId) => {}, destroySession: async (_sessionId) => {}, diff --git a/factory/packages/client/package.json b/factory/packages/client/package.json index 283ee44..50c462b 100644 --- a/factory/packages/client/package.json +++ b/factory/packages/client/package.json @@ -15,7 +15,8 @@ }, "dependencies": { "@openhandoff/shared": "workspace:*", - "rivetkit": "2.1.6" + "rivetkit": "2.1.6", + "sandbox-agent": "workspace:*" }, "devDependencies": { "tsup": "^8.5.0" diff --git a/factory/packages/client/src/backend-client.ts b/factory/packages/client/src/backend-client.ts index 4702ff8..ca5b9c6 100644 --- a/factory/packages/client/src/backend-client.ts +++ b/factory/packages/client/src/backend-client.ts @@ -29,6 +29,14 @@ import type { StarSandboxAgentRepoResult, SwitchResult, } from "@openhandoff/shared"; +import type { + ProcessCreateRequest, + ProcessInfo, + ProcessLogFollowQuery, + ProcessLogsResponse, + ProcessSignalQuery, +} from "sandbox-agent"; +import { createMockBackendClient } from "./mock/backend-client.js"; import { sandboxInstanceKey, workspaceKey } from "./keys.js"; export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill"; @@ -61,6 +69,8 @@ export interface SandboxSessionEventRecord { payload: unknown; } +export type SandboxProcessRecord = ProcessInfo; + interface WorkspaceHandle { addRepo(input: AddRepoInput): Promise; listRepos(input: { workspaceId: string }): Promise; @@ -104,8 +114,15 @@ interface SandboxInstanceHandle { }): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>; listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>; listSessionEvents(input: { sessionId: string; cursor?: string; limit?: number }): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>; + createProcess(input: ProcessCreateRequest): Promise; + listProcesses(): Promise<{ processes: SandboxProcessRecord[] }>; + getProcessLogs(input: { processId: string; query?: ProcessLogFollowQuery }): Promise; + stopProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise; + killProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise; + deleteProcess(input: { processId: string }): Promise; sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise; sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>; + sandboxAgentConnection(): Promise<{ endpoint: string; token?: string }>; providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; } @@ -121,6 +138,7 @@ interface RivetClient { export interface BackendClientOptions { endpoint: string; defaultWorkspaceId?: string; + mode?: "remote" | "mock"; } export interface BackendMetadata { @@ -163,6 +181,50 @@ export interface BackendClient { sandboxId: string, input: { sessionId: string; cursor?: string; limit?: number }, ): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>; + createSandboxProcess(input: { + workspaceId: string; + providerId: ProviderId; + sandboxId: string; + request: ProcessCreateRequest; + }): Promise; + listSandboxProcesses( + workspaceId: string, + providerId: ProviderId, + sandboxId: string + ): Promise<{ processes: SandboxProcessRecord[] }>; + getSandboxProcessLogs( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessLogFollowQuery + ): Promise; + stopSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessSignalQuery + ): Promise; + killSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessSignalQuery + ): Promise; + deleteSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string + ): Promise; + subscribeSandboxProcesses( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + listener: () => void + ): () => void; sendSandboxPrompt(input: { workspaceId: string; providerId: ProviderId; @@ -182,6 +244,11 @@ export interface BackendClient { providerId: ProviderId, sandboxId: string, ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; + getSandboxAgentConnection( + workspaceId: string, + providerId: ProviderId, + sandboxId: string + ): Promise<{ endpoint: string; token?: string }>; getWorkbench(workspaceId: string): Promise; subscribeWorkbench(workspaceId: string, listener: () => void): () => void; createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise; @@ -327,6 +394,10 @@ async function probeMetadataEndpoint(endpoint: string, namespace: string | undef } export function createBackendClient(options: BackendClientOptions): BackendClient { + if (options.mode === "mock") { + return createMockBackendClient(options.defaultWorkspaceId); + } + let clientPromise: Promise | null = null; const workbenchSubscriptions = new Map< string, @@ -335,6 +406,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien disposeConnPromise: Promise<(() => Promise) | null> | null; } >(); + const sandboxProcessSubscriptions = new Map< + string, + { + listeners: Set<() => void>; + disposeConnPromise: Promise<(() => Promise) | null> | null; + } + >(); const getClient = async (): Promise => { if (clientPromise) { @@ -495,6 +573,69 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; }; + const sandboxProcessSubscriptionKey = ( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + ): string => `${workspaceId}:${providerId}:${sandboxId}`; + + const subscribeSandboxProcesses = ( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + listener: () => void, + ): (() => void) => { + const key = sandboxProcessSubscriptionKey(workspaceId, providerId, sandboxId); + let entry = sandboxProcessSubscriptions.get(key); + if (!entry) { + entry = { + listeners: new Set(), + disposeConnPromise: null, + }; + sandboxProcessSubscriptions.set(key, entry); + } + + entry.listeners.add(listener); + + if (!entry.disposeConnPromise) { + entry.disposeConnPromise = (async () => { + const handle = await sandboxByKey(workspaceId, providerId, sandboxId); + const conn = (handle as any).connect(); + const unsubscribeEvent = conn.on("processesUpdated", () => { + const current = sandboxProcessSubscriptions.get(key); + if (!current) { + return; + } + for (const currentListener of [...current.listeners]) { + currentListener(); + } + }); + const unsubscribeError = conn.onError(() => {}); + return async () => { + unsubscribeEvent(); + unsubscribeError(); + await conn.dispose(); + }; + })().catch(() => null); + } + + return () => { + const current = sandboxProcessSubscriptions.get(key); + if (!current) { + return; + } + current.listeners.delete(listener); + if (current.listeners.size > 0) { + return; + } + + sandboxProcessSubscriptions.delete(key); + void current.disposeConnPromise?.then(async (disposeConn) => { + await disposeConn?.(); + }); + }; + }; + return { async addRepo(workspaceId: string, remoteUrl: string): Promise { return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl }); @@ -629,6 +770,101 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listSessionEvents(input)); }, + async createSandboxProcess(input: { + workspaceId: string; + providerId: ProviderId; + sandboxId: string; + request: ProcessCreateRequest; + }): Promise { + return await withSandboxHandle( + input.workspaceId, + input.providerId, + input.sandboxId, + async (handle) => handle.createProcess(input.request) + ); + }, + + async listSandboxProcesses( + workspaceId: string, + providerId: ProviderId, + sandboxId: string + ): Promise<{ processes: SandboxProcessRecord[] }> { + return await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.listProcesses() + ); + }, + + async getSandboxProcessLogs( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessLogFollowQuery + ): Promise { + return await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.getProcessLogs({ processId, query }) + ); + }, + + async stopSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessSignalQuery + ): Promise { + return await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.stopProcess({ processId, query }) + ); + }, + + async killSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessSignalQuery + ): Promise { + return await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.killProcess({ processId, query }) + ); + }, + + async deleteSandboxProcess( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + processId: string + ): Promise { + await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.deleteProcess({ processId }) + ); + }, + + subscribeSandboxProcesses( + workspaceId: string, + providerId: ProviderId, + sandboxId: string, + listener: () => void + ): () => void { + return subscribeSandboxProcesses(workspaceId, providerId, sandboxId, listener); + }, + async sendSandboxPrompt(input: { workspaceId: string; providerId: ProviderId; @@ -663,6 +899,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState()); }, + async getSandboxAgentConnection( + workspaceId: string, + providerId: ProviderId, + sandboxId: string + ): Promise<{ endpoint: string; token?: string }> { + return await withSandboxHandle( + workspaceId, + providerId, + sandboxId, + async (handle) => handle.sandboxAgentConnection() + ); + }, + async getWorkbench(workspaceId: string): Promise { return (await workspace(workspaceId)).getWorkbench({ workspaceId }); }, diff --git a/factory/packages/client/src/mock/backend-client.ts b/factory/packages/client/src/mock/backend-client.ts new file mode 100644 index 0000000..543f515 --- /dev/null +++ b/factory/packages/client/src/mock/backend-client.ts @@ -0,0 +1,505 @@ +import type { + AddRepoInput, + CreateHandoffInput, + HandoffRecord, + HandoffSummary, + HandoffWorkbenchChangeModelInput, + HandoffWorkbenchCreateHandoffInput, + HandoffWorkbenchCreateHandoffResponse, + HandoffWorkbenchDiffInput, + HandoffWorkbenchRenameInput, + HandoffWorkbenchRenameSessionInput, + HandoffWorkbenchSelectInput, + HandoffWorkbenchSetSessionUnreadInput, + HandoffWorkbenchSendMessageInput, + HandoffWorkbenchSnapshot, + HandoffWorkbenchTabInput, + HandoffWorkbenchUpdateDraftInput, + HistoryEvent, + HistoryQueryInput, + ProviderId, + RepoOverview, + RepoRecord, + RepoStackActionInput, + RepoStackActionResult, + StarSandboxAgentRepoResult, + SwitchResult, +} from "@openhandoff/shared"; +import type { + ProcessCreateRequest, + ProcessLogFollowQuery, + ProcessLogsResponse, + ProcessSignalQuery, +} from "sandbox-agent"; +import type { + BackendClient, + SandboxProcessRecord, + SandboxSessionEventRecord, + SandboxSessionRecord, +} from "../backend-client.js"; +import { getSharedMockWorkbenchClient } from "./workbench-client.js"; + +interface MockProcessRecord extends SandboxProcessRecord { + logText: string; +} + +function notSupported(name: string): never { + throw new Error(`${name} is not supported by the mock backend client.`); +} + +function encodeBase64Utf8(value: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(value, "utf8").toString("base64"); + } + return globalThis.btoa(unescape(encodeURIComponent(value))); +} + +function nowMs(): number { + return Date.now(); +} + +function mockRepoRemote(label: string): string { + return `https://example.test/${label}.git`; +} + +function mockCwd(repoLabel: string, handoffId: string): string { + return `/mock/${repoLabel.replace(/\//g, "-")}/${handoffId}`; +} + +function toHandoffStatus(status: HandoffRecord["status"], archived: boolean): HandoffRecord["status"] { + if (archived) { + return "archived"; + } + return status; +} + +export function createMockBackendClient(defaultWorkspaceId = "default"): BackendClient { + const workbench = getSharedMockWorkbenchClient(); + const listenersBySandboxId = new Map void>>(); + const processesBySandboxId = new Map(); + let nextPid = 4000; + let nextProcessId = 1; + + const requireHandoff = (handoffId: string) => { + const handoff = workbench.getSnapshot().handoffs.find((candidate) => candidate.id === handoffId); + if (!handoff) { + throw new Error(`Unknown mock handoff ${handoffId}`); + } + return handoff; + }; + + const ensureProcessList = (sandboxId: string): MockProcessRecord[] => { + const existing = processesBySandboxId.get(sandboxId); + if (existing) { + return existing; + } + const created: MockProcessRecord[] = []; + processesBySandboxId.set(sandboxId, created); + return created; + }; + + const notifySandbox = (sandboxId: string): void => { + const listeners = listenersBySandboxId.get(sandboxId); + if (!listeners) { + return; + } + for (const listener of [...listeners]) { + listener(); + } + }; + + const buildHandoffRecord = (handoffId: string): HandoffRecord => { + const handoff = requireHandoff(handoffId); + const cwd = mockCwd(handoff.repoName, handoff.id); + const archived = handoff.status === "archived"; + return { + workspaceId: defaultWorkspaceId, + repoId: handoff.repoId, + repoRemote: mockRepoRemote(handoff.repoName), + handoffId: handoff.id, + branchName: handoff.branch, + title: handoff.title, + task: handoff.title, + providerId: "local", + status: toHandoffStatus(archived ? "archived" : "running", archived), + statusMessage: archived ? "archived" : "mock sandbox ready", + activeSandboxId: handoff.id, + activeSessionId: handoff.tabs[0]?.sessionId ?? null, + sandboxes: [ + { + sandboxId: handoff.id, + providerId: "local", + sandboxActorId: "mock-sandbox", + switchTarget: `mock://${handoff.id}`, + cwd, + createdAt: handoff.updatedAtMs, + updatedAt: handoff.updatedAtMs, + }, + ], + agentType: handoff.tabs[0]?.agent === "Codex" ? "codex" : "claude", + prSubmitted: Boolean(handoff.pullRequest), + diffStat: handoff.fileChanges.length > 0 ? `+${handoff.fileChanges.length}/-${handoff.fileChanges.length}` : "+0/-0", + prUrl: handoff.pullRequest ? `https://example.test/pr/${handoff.pullRequest.number}` : null, + prAuthor: handoff.pullRequest ? "mock" : null, + ciStatus: null, + reviewStatus: null, + reviewer: null, + conflictsWithMain: "0", + hasUnpushed: handoff.fileChanges.length > 0 ? "1" : "0", + parentBranch: null, + createdAt: handoff.updatedAtMs, + updatedAt: handoff.updatedAtMs, + }; + }; + + const cloneProcess = (process: MockProcessRecord): MockProcessRecord => ({ ...process }); + + const createProcessRecord = ( + sandboxId: string, + cwd: string, + request: ProcessCreateRequest, + ): MockProcessRecord => { + const processId = `proc_${nextProcessId++}`; + const createdAtMs = nowMs(); + const args = request.args ?? []; + const interactive = request.interactive ?? false; + const tty = request.tty ?? false; + const statusLine = interactive && tty + ? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n" + : "Mock process created.\n"; + const commandLine = `$ ${[request.command, ...args].join(" ").trim()}\n`; + return { + id: processId, + command: request.command, + args, + createdAtMs, + cwd: request.cwd ?? cwd, + exitCode: null, + exitedAtMs: null, + interactive, + pid: nextPid++, + status: "running", + tty, + logText: `${statusLine}${commandLine}`, + }; + }; + + return { + async addRepo(_workspaceId: string, _remoteUrl: string): Promise { + notSupported("addRepo"); + }, + + async listRepos(_workspaceId: string): Promise { + return workbench.getSnapshot().repos.map((repo) => ({ + workspaceId: defaultWorkspaceId, + repoId: repo.id, + remoteUrl: mockRepoRemote(repo.label), + createdAt: nowMs(), + updatedAt: nowMs(), + })); + }, + + async createHandoff(_input: CreateHandoffInput): Promise { + notSupported("createHandoff"); + }, + + async listHandoffs(_workspaceId: string, repoId?: string): Promise { + return workbench + .getSnapshot() + .handoffs + .filter((handoff) => !repoId || handoff.repoId === repoId) + .map((handoff) => ({ + workspaceId: defaultWorkspaceId, + repoId: handoff.repoId, + handoffId: handoff.id, + branchName: handoff.branch, + title: handoff.title, + status: handoff.status === "archived" ? "archived" : "running", + updatedAt: handoff.updatedAtMs, + })); + }, + + async getRepoOverview(_workspaceId: string, _repoId: string): Promise { + notSupported("getRepoOverview"); + }, + + async runRepoStackAction(_input: RepoStackActionInput): Promise { + notSupported("runRepoStackAction"); + }, + + async getHandoff(_workspaceId: string, handoffId: string): Promise { + return buildHandoffRecord(handoffId); + }, + + async listHistory(_input: HistoryQueryInput): Promise { + return []; + }, + + async switchHandoff(_workspaceId: string, handoffId: string): Promise { + return { + workspaceId: defaultWorkspaceId, + handoffId, + providerId: "local", + switchTarget: `mock://${handoffId}`, + }; + }, + + async attachHandoff(_workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> { + return { + target: `mock://${handoffId}`, + sessionId: requireHandoff(handoffId).tabs[0]?.sessionId ?? null, + }; + }, + + async runAction(_workspaceId: string, _handoffId: string): Promise { + notSupported("runAction"); + }, + + async createSandboxSession(): Promise<{ id: string; status: "running" | "idle" | "error" }> { + notSupported("createSandboxSession"); + }, + + async listSandboxSessions(): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> { + return { items: [] }; + }, + + async listSandboxSessionEvents(): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> { + return { items: [] }; + }, + + async createSandboxProcess(input: { + workspaceId: string; + providerId: ProviderId; + sandboxId: string; + request: ProcessCreateRequest; + }): Promise { + const handoff = requireHandoff(input.sandboxId); + const processes = ensureProcessList(input.sandboxId); + const created = createProcessRecord(input.sandboxId, mockCwd(handoff.repoName, handoff.id), input.request); + processes.unshift(created); + notifySandbox(input.sandboxId); + return cloneProcess(created); + }, + + async listSandboxProcesses( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + ): Promise<{ processes: SandboxProcessRecord[] }> { + return { + processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)), + }; + }, + + async getSandboxProcessLogs( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + processId: string, + query?: ProcessLogFollowQuery, + ): Promise { + const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId); + if (!process) { + throw new Error(`Unknown mock process ${processId}`); + } + return { + processId, + stream: query?.stream ?? (process.tty ? "pty" : "combined"), + entries: process.logText + ? [ + { + data: encodeBase64Utf8(process.logText), + encoding: "base64", + sequence: 1, + stream: query?.stream ?? (process.tty ? "pty" : "combined"), + timestampMs: process.createdAtMs, + }, + ] + : [], + }; + }, + + async stopSandboxProcess( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + processId: string, + _query?: ProcessSignalQuery, + ): Promise { + const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId); + if (!process) { + throw new Error(`Unknown mock process ${processId}`); + } + process.status = "exited"; + process.exitCode = 0; + process.exitedAtMs = nowMs(); + process.logText += "\n[stopped]\n"; + notifySandbox(sandboxId); + return cloneProcess(process); + }, + + async killSandboxProcess( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + processId: string, + _query?: ProcessSignalQuery, + ): Promise { + const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId); + if (!process) { + throw new Error(`Unknown mock process ${processId}`); + } + process.status = "exited"; + process.exitCode = 137; + process.exitedAtMs = nowMs(); + process.logText += "\n[killed]\n"; + notifySandbox(sandboxId); + return cloneProcess(process); + }, + + async deleteSandboxProcess( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + processId: string, + ): Promise { + processesBySandboxId.set( + sandboxId, + ensureProcessList(sandboxId).filter((candidate) => candidate.id !== processId), + ); + notifySandbox(sandboxId); + }, + + subscribeSandboxProcesses( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + listener: () => void, + ): () => void { + let listeners = listenersBySandboxId.get(sandboxId); + if (!listeners) { + listeners = new Set(); + listenersBySandboxId.set(sandboxId, listeners); + } + listeners.add(listener); + return () => { + const current = listenersBySandboxId.get(sandboxId); + if (!current) { + return; + } + current.delete(listener); + if (current.size === 0) { + listenersBySandboxId.delete(sandboxId); + } + }; + }, + + async sendSandboxPrompt(): Promise { + notSupported("sendSandboxPrompt"); + }, + + async sandboxSessionStatus(sessionId: string): Promise<{ id: string; status: "running" | "idle" | "error" }> { + return { id: sessionId, status: "idle" }; + }, + + async sandboxProviderState( + _workspaceId: string, + _providerId: ProviderId, + sandboxId: string, + ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> { + return { providerId: "local", sandboxId, state: "running", at: nowMs() }; + }, + + async getSandboxAgentConnection(): Promise<{ endpoint: string; token?: string }> { + return { endpoint: "mock://terminal-unavailable" }; + }, + + async getWorkbench(): Promise { + return workbench.getSnapshot(); + }, + + subscribeWorkbench(_workspaceId: string, listener: () => void): () => void { + return workbench.subscribe(listener); + }, + + async createWorkbenchHandoff( + _workspaceId: string, + input: HandoffWorkbenchCreateHandoffInput, + ): Promise { + return await workbench.createHandoff(input); + }, + + async markWorkbenchUnread(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + await workbench.markHandoffUnread(input); + }, + + async renameWorkbenchHandoff(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { + await workbench.renameHandoff(input); + }, + + async renameWorkbenchBranch(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { + await workbench.renameBranch(input); + }, + + async createWorkbenchSession( + _workspaceId: string, + input: HandoffWorkbenchSelectInput & { model?: string }, + ): Promise<{ tabId: string }> { + return await workbench.addTab(input); + }, + + async renameWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise { + await workbench.renameSession(input); + }, + + async setWorkbenchSessionUnread( + _workspaceId: string, + input: HandoffWorkbenchSetSessionUnreadInput, + ): Promise { + await workbench.setSessionUnread(input); + }, + + async updateWorkbenchDraft(_workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise { + await workbench.updateDraft(input); + }, + + async changeWorkbenchModel(_workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise { + await workbench.changeModel(input); + }, + + async sendWorkbenchMessage(_workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise { + await workbench.sendMessage(input); + }, + + async stopWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + await workbench.stopAgent(input); + }, + + async closeWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + await workbench.closeTab(input); + }, + + async publishWorkbenchPr(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + await workbench.publishPr(input); + }, + + async revertWorkbenchFile(_workspaceId: string, input: HandoffWorkbenchDiffInput): Promise { + await workbench.revertFile(input); + }, + + async health(): Promise<{ ok: true }> { + return { ok: true }; + }, + + async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> { + return { workspaceId }; + }, + + async starSandboxAgentRepo(): Promise { + return { + repo: "rivet-dev/sandbox-agent", + starredAt: nowMs(), + }; + }, + }; +} diff --git a/factory/packages/frontend/package.json b/factory/packages/frontend/package.json index d9d61f9..ef01d9a 100644 --- a/factory/packages/frontend/package.json +++ b/factory/packages/frontend/package.json @@ -20,6 +20,7 @@ "lucide-react": "^0.542.0", "react": "^19.1.1", "react-dom": "^19.1.1", + "sandbox-agent": "workspace:*", "styletron-engine-atomic": "^1.6.2", "styletron-react": "^6.1.1" }, diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/factory/packages/frontend/src/components/mock-layout.tsx index dd4936e..277c49d 100644 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ b/factory/packages/frontend/src/components/mock-layout.tsx @@ -1,5 +1,16 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type PointerEvent as ReactPointerEvent, +} from "react"; import { useNavigate } from "@tanstack/react-router"; +import { useStyletron } from "baseui"; import { DiffContent } from "./mock-layout/diff-content"; import { MessageList } from "./mock-layout/message-list"; @@ -7,6 +18,7 @@ import { PromptComposer } from "./mock-layout/prompt-composer"; import { RightSidebar } from "./mock-layout/right-sidebar"; import { Sidebar } from "./mock-layout/sidebar"; import { TabStrip } from "./mock-layout/tab-strip"; +import { TerminalPane } from "./mock-layout/terminal-pane"; import { TranscriptHeader } from "./mock-layout/transcript-header"; import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui"; import { @@ -548,6 +560,157 @@ const TranscriptPanel = memo(function TranscriptPanel({ ); }); +const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180; +const RIGHT_RAIL_SPLITTER_HEIGHT = 10; +const DEFAULT_TERMINAL_HEIGHT = 320; +const TERMINAL_HEIGHT_STORAGE_KEY = "openhandoff:foundry-terminal-height"; + +const RightRail = memo(function RightRail({ + workspaceId, + handoff, + activeTabId, + onOpenDiff, + onArchive, + onRevertFile, + onPublishPr, +}: { + workspaceId: string; + handoff: Handoff; + activeTabId: string | null; + onOpenDiff: (path: string) => void; + onArchive: () => void; + onRevertFile: (path: string) => void; + onPublishPr: () => void; +}) { + const [css] = useStyletron(); + const railRef = useRef(null); + const [terminalHeight, setTerminalHeight] = useState(() => { + if (typeof window === "undefined") { + return DEFAULT_TERMINAL_HEIGHT; + } + + const stored = window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY); + const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN; + return Number.isFinite(parsed) ? parsed : DEFAULT_TERMINAL_HEIGHT; + }); + + const clampTerminalHeight = useCallback((nextHeight: number) => { + const railHeight = railRef.current?.getBoundingClientRect().height ?? 0; + const maxHeight = Math.max( + RIGHT_RAIL_MIN_SECTION_HEIGHT, + railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT, + ); + + return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight)); + }, [terminalHeight]); + + useEffect(() => { + const handleResize = () => { + setTerminalHeight((current) => clampTerminalHeight(current)); + }; + + window.addEventListener("resize", handleResize); + handleResize(); + return () => window.removeEventListener("resize", handleResize); + }, [clampTerminalHeight]); + + const startResize = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + + const startY = event.clientY; + const startHeight = terminalHeight; + document.body.style.cursor = "ns-resize"; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const deltaY = moveEvent.clientY - startY; + setTerminalHeight(clampTerminalHeight(startHeight - deltaY)); + }; + + const stopResize = () => { + document.body.style.cursor = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResize); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResize, { once: true }); + }, + [clampTerminalHeight, terminalHeight], + ); + + return ( +
+
+ +
+
+
+ +
+
+ ); +}); + interface MockLayoutProps { workspaceId: string; selectedHandoffId?: string | null; @@ -1057,7 +1220,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); }} /> - char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return value; + } +} + +function parseArgs(value: string): string[] { + return value + .split("\n") + .map((part) => part.trim()) + .filter(Boolean); +} + +function formatCommandSummary(process: Pick): string { + return [process.command, ...process.args].join(" ").trim(); +} + +function canOpenTerminal(process: SandboxProcessRecord | null | undefined): boolean { + return Boolean(process && process.status === "running" && process.interactive && process.tty); +} + +function defaultShellRequest(cwd?: string | null) { + return { + command: "/bin/bash", + args: [ + "-lc", + 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec "$SHELL" -l; fi; if [ -x /bin/zsh ]; then exec /bin/zsh -l; fi; exec /bin/bash -l', + ], + cwd: cwd ?? undefined, + interactive: true, + tty: true, + }; +} + +function formatProcessTabTitle(process: Pick, fallbackIndex: number): string { + const label = process.command.split("/").pop()?.trim(); + return label && label.length > 0 ? label : `Terminal ${fallbackIndex}`; +} + +export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { + const [css] = useStyletron(); + const [activeTabId, setActiveTabId] = useState(PROCESSES_TAB_ID); + const [processTabs, setProcessTabs] = useState([]); + const [selectedProcessId, setSelectedProcessId] = useState(null); + const [command, setCommand] = useState(""); + const [argsText, setArgsText] = useState(""); + const [cwdOverride, setCwdOverride] = useState(""); + const [interactive, setInteractive] = useState(true); + const [tty, setTty] = useState(true); + const [createError, setCreateError] = useState(null); + const [creatingProcess, setCreatingProcess] = useState(false); + const [actingProcessId, setActingProcessId] = useState(null); + const [logsText, setLogsText] = useState(""); + const [logsLoading, setLogsLoading] = useState(false); + const [logsError, setLogsError] = useState(null); + const [terminalClient, setTerminalClient] = useState(null); + + const handoffQuery = useQuery({ + queryKey: ["mock-layout", "handoff", workspaceId, handoffId], + enabled: Boolean(handoffId), + staleTime: 1_000, + refetchOnWindowFocus: true, + refetchInterval: (query) => (query.state.data?.activeSandboxId ? false : 2_000), + queryFn: async () => { + if (!handoffId) { + throw new Error("Cannot load terminal state without a handoff."); + } + return await backendClient.getHandoff(workspaceId, handoffId); + }, + }); + + const activeSandbox = useMemo(() => { + const handoff = handoffQuery.data; + if (!handoff?.activeSandboxId) { + return null; + } + + return handoff.sandboxes.find((sandbox) => sandbox.sandboxId === handoff.activeSandboxId) ?? null; + }, [handoffQuery.data]); + + const connectionQuery = useQuery({ + queryKey: [ + "mock-layout", + "sandbox-agent-connection", + workspaceId, + activeSandbox?.providerId ?? "", + activeSandbox?.sandboxId ?? "", + ], + enabled: Boolean(activeSandbox?.sandboxId), + staleTime: 30_000, + refetchOnWindowFocus: false, + queryFn: async () => { + if (!activeSandbox) { + throw new Error("Cannot load a sandbox connection without an active sandbox."); + } + + return await backendClient.getSandboxAgentConnection( + workspaceId, + activeSandbox.providerId, + activeSandbox.sandboxId, + ); + }, + }); + + 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]); + + useEffect(() => { + if (!connectionQuery.data) { + setTerminalClient((current) => { + if (current) { + void current.dispose(); + } + return null; + }); + return; + } + + if (connectionQuery.data.endpoint.startsWith("mock://")) { + setTerminalClient((current) => { + if (current) { + void current.dispose(); + } + return null; + }); + return; + } + + let cancelled = false; + void SandboxAgent.connect({ + baseUrl: connectionQuery.data.endpoint, + token: connectionQuery.data.token, + waitForHealth: false, + }) + .then((client) => { + if (cancelled) { + void client.dispose(); + return; + } + + setTerminalClient((current) => { + if (current) { + void current.dispose(); + } + return client; + }); + }) + .catch(() => { + if (!cancelled) { + setTerminalClient((current) => { + if (current) { + void current.dispose(); + } + return null; + }); + } + }); + + return () => { + cancelled = true; + }; + }, [connectionQuery.data]); + + useEffect(() => { + return () => { + if (terminalClient) { + void terminalClient.dispose(); + } + }; + }, [terminalClient]); + + useEffect(() => { + setActiveTabId(PROCESSES_TAB_ID); + setProcessTabs([]); + setSelectedProcessId(null); + setLogsText(""); + setLogsError(null); + }, [handoffId]); + + const processes = processesQuery.data?.processes ?? []; + const selectedProcess = useMemo( + () => processes.find((process) => process.id === selectedProcessId) ?? null, + [processes, selectedProcessId], + ); + + useEffect(() => { + if (!processes.length) { + setSelectedProcessId(null); + return; + } + + setSelectedProcessId((current) => { + if (current && processes.some((process) => process.id === current)) { + return current; + } + return processes[0]?.id ?? null; + }); + }, [processes]); + + const refreshLogs = useCallback(async () => { + if (!activeSandbox?.sandboxId || !selectedProcess) { + setLogsText(""); + setLogsError(null); + return; + } + + setLogsLoading(true); + setLogsError(null); + try { + const response = await backendClient.getSandboxProcessLogs( + workspaceId, + activeSandbox.providerId, + activeSandbox.sandboxId, + selectedProcess.id, + { + stream: selectedProcess.tty ? "pty" : "combined", + tail: 200, + }, + ); + setLogsText( + response.entries + .map((entry: ProcessLogResponseEntry) => decodeBase64Utf8(entry.data)) + .join(""), + ); + } catch (error) { + setLogsText(""); + setLogsError(error instanceof Error ? error.message : String(error)); + } finally { + setLogsLoading(false); + } + }, [activeSandbox, selectedProcess, workspaceId]); + + useEffect(() => { + void refreshLogs(); + }, [refreshLogs]); + + const openTerminalTab = useCallback((process: SandboxProcessRecord) => { + setProcessTabs((current) => { + const existing = current.find((tab) => tab.processId === process.id); + if (existing) { + setActiveTabId(existing.id); + return current; + } + + const nextTab: ProcessTab = { + id: `terminal:${process.id}`, + processId: process.id, + title: formatProcessTabTitle(process, current.length + 1), + }; + setActiveTabId(nextTab.id); + return [...current, nextTab]; + }); + }, []); + + const closeTerminalTab = useCallback((tabId: string) => { + setProcessTabs((current) => current.filter((tab) => tab.id !== tabId)); + setActiveTabId((current) => (current === tabId ? PROCESSES_TAB_ID : current)); + }, []); + + const spawnTerminal = useCallback(async () => { + if (!activeSandbox?.sandboxId) { + return; + } + + setCreatingProcess(true); + setCreateError(null); + try { + const created = await backendClient.createSandboxProcess({ + workspaceId, + providerId: activeSandbox.providerId, + sandboxId: activeSandbox.sandboxId, + request: defaultShellRequest(activeSandbox.cwd), + }); + await processesQuery.refetch(); + openTerminalTab(created); + } catch (error) { + setCreateError(error instanceof Error ? error.message : String(error)); + } finally { + setCreatingProcess(false); + } + }, [activeSandbox, openTerminalTab, processesQuery, workspaceId]); + + const createCustomProcess = useCallback(async () => { + if (!activeSandbox?.sandboxId) { + return; + } + + const trimmedCommand = command.trim(); + if (!trimmedCommand) { + setCreateError("Command is required."); + return; + } + + setCreatingProcess(true); + setCreateError(null); + try { + const created = await backendClient.createSandboxProcess({ + workspaceId, + providerId: activeSandbox.providerId, + sandboxId: activeSandbox.sandboxId, + request: { + command: trimmedCommand, + args: parseArgs(argsText), + cwd: cwdOverride.trim() || activeSandbox.cwd || undefined, + interactive, + tty, + }, + }); + await processesQuery.refetch(); + setSelectedProcessId(created.id); + setCommand(""); + setArgsText(""); + setCwdOverride(""); + setInteractive(true); + setTty(true); + if (created.interactive && created.tty) { + openTerminalTab(created); + } else { + setActiveTabId(PROCESSES_TAB_ID); + } + } catch (error) { + setCreateError(error instanceof Error ? error.message : String(error)); + } finally { + setCreatingProcess(false); + } + }, [ + activeSandbox, + argsText, + command, + cwdOverride, + interactive, + openTerminalTab, + processesQuery, + tty, + workspaceId, + ]); + + const handleProcessAction = useCallback( + async (processId: string, action: "stop" | "kill" | "delete") => { + if (!activeSandbox?.sandboxId) { + return; + } + + setActingProcessId(`${action}:${processId}`); + try { + if (action === "stop") { + await backendClient.stopSandboxProcess( + workspaceId, + activeSandbox.providerId, + activeSandbox.sandboxId, + processId, + { waitMs: 2_000 }, + ); + } else if (action === "kill") { + await backendClient.killSandboxProcess( + workspaceId, + activeSandbox.providerId, + activeSandbox.sandboxId, + processId, + { waitMs: 2_000 }, + ); + } else { + await backendClient.deleteSandboxProcess( + workspaceId, + activeSandbox.providerId, + activeSandbox.sandboxId, + processId, + ); + setProcessTabs((current) => current.filter((tab) => tab.processId !== processId)); + setActiveTabId((current) => + current.startsWith("terminal:") && current === `terminal:${processId}` ? PROCESSES_TAB_ID : current, + ); + } + await processesQuery.refetch(); + } catch (error) { + setCreateError(error instanceof Error ? error.message : String(error)); + } finally { + setActingProcessId(null); + } + }, + [activeSandbox, processesQuery, workspaceId], + ); + + const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]); + const activeProcessTab = activeTabId === PROCESSES_TAB_ID ? null : processTabsById.get(activeTabId) ?? null; + const activeTerminalProcess = useMemo( + () => (activeProcessTab ? processes.find((process) => process.id === activeProcessTab.processId) ?? null : null), + [activeProcessTab, processes], + ); + + const emptyBodyClassName = css({ + flex: 1, + minHeight: `${MIN_TERMINAL_HEIGHT}px`, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "24px", + backgroundColor: "#080506", + }); + + const emptyCopyClassName = css({ + maxWidth: "340px", + display: "flex", + flexDirection: "column", + gap: "10px", + color: "rgba(255, 255, 255, 0.72)", + fontSize: "12px", + lineHeight: 1.6, + textAlign: "center", + }); + + const smallButtonClassName = css({ + all: "unset", + display: "inline-flex", + alignItems: "center", + gap: "6px", + padding: "6px 10px", + borderRadius: "8px", + border: "1px solid rgba(255, 255, 255, 0.1)", + color: "#f4f4f5", + cursor: "pointer", + fontSize: "11px", + fontWeight: 600, + ":hover": { + backgroundColor: "rgba(255, 255, 255, 0.06)", + }, + ":disabled": { + opacity: 0.45, + cursor: "not-allowed", + }, + }); + + const renderProcessesView = () => { + if (!activeSandbox?.sandboxId) { + return ( +
+
+ Processes will appear when the sandbox is ready. + The active handoff does not have a sandbox runtime yet. +
+
+ ); + } + + return ( +
+
+
+
+ Processes + + Process lifecycle goes through the actor. Terminal transport goes straight to the sandbox. + +
+
+ + +
+
+ +
+ { + setCommand(event.target.value); + setCreateError(null); + }} + placeholder="Command" + /> + { + setCwdOverride(event.target.value); + setCreateError(null); + }} + placeholder={activeSandbox.cwd ?? "Working directory"} + /> +