mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
Merge pull request #234 from rivet-dev/foundry-terminal-pane
Add Foundry terminal and process pane
This commit is contained in:
commit
20082512a3
15 changed files with 2345 additions and 25 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
initCreateSessionActivity,
|
||||
initEnsureAgentActivity,
|
||||
initEnsureNameActivity,
|
||||
initExposeSandboxActivity,
|
||||
initFailedActivity,
|
||||
initStartSandboxInstanceActivity,
|
||||
initStartStatusSyncActivity,
|
||||
|
|
@ -90,6 +91,10 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; token?: string } | null> {
|
||||
async function loadPersistedAgentConfig(c: any): Promise<SandboxAgentConnection | null> {
|
||||
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<SandboxAgentConnection> {
|
||||
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<SandboxAgentConnection> {
|
||||
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<SandboxAgentConnection> {
|
||||
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<void> {
|
||||
const now = Date.now();
|
||||
const metadata = {
|
||||
|
|
@ -446,6 +466,56 @@ export const sandboxInstance = actor({
|
|||
sandboxId: input.sandboxId,
|
||||
}),
|
||||
actions: {
|
||||
async sandboxAgentConnection(c: any): Promise<SandboxAgentConnection> {
|
||||
return await loadAgentConfig(c);
|
||||
},
|
||||
|
||||
async createProcess(c: any, request: ProcessCreateRequest): Promise<ProcessInfo> {
|
||||
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<ProcessLogsResponse> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
return await client.getProcessLogs(request.processId, request.query);
|
||||
},
|
||||
|
||||
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);
|
||||
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);
|
||||
return killed;
|
||||
},
|
||||
|
||||
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<SandboxSession>;
|
||||
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
|
||||
listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>>;
|
||||
createProcess(request: ProcessCreateRequest): Promise<ProcessInfo>;
|
||||
listProcesses(): Promise<{ processes: ProcessInfo[] }>;
|
||||
getProcessLogs(processId: string, query?: ProcessLogFollowQuery): Promise<ProcessLogsResponse>;
|
||||
stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo>;
|
||||
killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo>;
|
||||
deleteProcess(processId: string): Promise<void>;
|
||||
sendPrompt(request: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
|
||||
cancelSession(sessionId: string): Promise<void>;
|
||||
destroySession(sessionId: string): Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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<ProcessInfo> {
|
||||
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<ProcessLogsResponse> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.getProcessLogs(processId, query);
|
||||
}
|
||||
|
||||
async stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.stopProcess(processId, query);
|
||||
}
|
||||
|
||||
async killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
const sdk = await this.sdk();
|
||||
return await sdk.killProcess(processId, query);
|
||||
}
|
||||
|
||||
async deleteProcess(processId: string): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
await sdk.deleteProcess(processId);
|
||||
}
|
||||
|
||||
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
|
||||
const sdk = await this.sdk();
|
||||
const existing = await sdk.getSession(request.sessionId);
|
||||
|
|
|
|||
|
|
@ -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>): BackendDriver {
|
||||
return {
|
||||
|
|
@ -70,7 +78,27 @@ export function createTestSandboxAgentDriver(overrides?: Partial<SandboxAgentDri
|
|||
};
|
||||
}
|
||||
|
||||
export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentClientLike>): SandboxAgentClientLike {
|
||||
export function createTestSandboxAgentClient(
|
||||
overrides?: Partial<SandboxAgentClientLike>
|
||||
): 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<SandboxAgentCli
|
|||
items: [],
|
||||
nextCursor: undefined,
|
||||
}),
|
||||
createProcess: async () => 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) => {},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@openhandoff/shared": "workspace:*",
|
||||
"rivetkit": "2.1.6"
|
||||
"rivetkit": "2.1.6",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0"
|
||||
|
|
|
|||
|
|
@ -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<RepoRecord>;
|
||||
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
|
||||
|
|
@ -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<SandboxProcessRecord>;
|
||||
listProcesses(): Promise<{ processes: SandboxProcessRecord[] }>;
|
||||
getProcessLogs(input: { processId: string; query?: ProcessLogFollowQuery }): Promise<ProcessLogsResponse>;
|
||||
stopProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise<SandboxProcessRecord>;
|
||||
killProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise<SandboxProcessRecord>;
|
||||
deleteProcess(input: { processId: string }): Promise<void>;
|
||||
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
|
||||
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<SandboxProcessRecord>;
|
||||
listSandboxProcesses(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string
|
||||
): Promise<{ processes: SandboxProcessRecord[] }>;
|
||||
getSandboxProcessLogs(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
query?: ProcessLogFollowQuery
|
||||
): Promise<ProcessLogsResponse>;
|
||||
stopSandboxProcess(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
query?: ProcessSignalQuery
|
||||
): Promise<SandboxProcessRecord>;
|
||||
killSandboxProcess(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
query?: ProcessSignalQuery
|
||||
): Promise<SandboxProcessRecord>;
|
||||
deleteSandboxProcess(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string
|
||||
): Promise<void>;
|
||||
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<HandoffWorkbenchSnapshot>;
|
||||
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
|
||||
createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
|
||||
|
|
@ -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<RivetClient> | null = null;
|
||||
const workbenchSubscriptions = new Map<
|
||||
string,
|
||||
|
|
@ -335,6 +406,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||
}
|
||||
>();
|
||||
const sandboxProcessSubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
listeners: Set<() => void>;
|
||||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
const getClient = async (): Promise<RivetClient> => {
|
||||
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<RepoRecord> {
|
||||
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<SandboxProcessRecord> {
|
||||
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<ProcessLogsResponse> {
|
||||
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<SandboxProcessRecord> {
|
||||
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<SandboxProcessRecord> {
|
||||
return await withSandboxHandle(
|
||||
workspaceId,
|
||||
providerId,
|
||||
sandboxId,
|
||||
async (handle) => handle.killProcess({ processId, query })
|
||||
);
|
||||
},
|
||||
|
||||
async deleteSandboxProcess(
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string
|
||||
): Promise<void> {
|
||||
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<HandoffWorkbenchSnapshot> {
|
||||
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
|
||||
},
|
||||
|
|
|
|||
505
factory/packages/client/src/mock/backend-client.ts
Normal file
505
factory/packages/client/src/mock/backend-client.ts
Normal file
|
|
@ -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<string, Set<() => void>>();
|
||||
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
|
||||
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<RepoRecord> {
|
||||
notSupported("addRepo");
|
||||
},
|
||||
|
||||
async listRepos(_workspaceId: string): Promise<RepoRecord[]> {
|
||||
return workbench.getSnapshot().repos.map((repo) => ({
|
||||
workspaceId: defaultWorkspaceId,
|
||||
repoId: repo.id,
|
||||
remoteUrl: mockRepoRemote(repo.label),
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs(),
|
||||
}));
|
||||
},
|
||||
|
||||
async createHandoff(_input: CreateHandoffInput): Promise<HandoffRecord> {
|
||||
notSupported("createHandoff");
|
||||
},
|
||||
|
||||
async listHandoffs(_workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
|
||||
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<RepoOverview> {
|
||||
notSupported("getRepoOverview");
|
||||
},
|
||||
|
||||
async runRepoStackAction(_input: RepoStackActionInput): Promise<RepoStackActionResult> {
|
||||
notSupported("runRepoStackAction");
|
||||
},
|
||||
|
||||
async getHandoff(_workspaceId: string, handoffId: string): Promise<HandoffRecord> {
|
||||
return buildHandoffRecord(handoffId);
|
||||
},
|
||||
|
||||
async listHistory(_input: HistoryQueryInput): Promise<HistoryEvent[]> {
|
||||
return [];
|
||||
},
|
||||
|
||||
async switchHandoff(_workspaceId: string, handoffId: string): Promise<SwitchResult> {
|
||||
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<void> {
|
||||
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<SandboxProcessRecord> {
|
||||
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<ProcessLogsResponse> {
|
||||
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<SandboxProcessRecord> {
|
||||
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<SandboxProcessRecord> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<HandoffWorkbenchSnapshot> {
|
||||
return workbench.getSnapshot();
|
||||
},
|
||||
|
||||
subscribeWorkbench(_workspaceId: string, listener: () => void): () => void {
|
||||
return workbench.subscribe(listener);
|
||||
},
|
||||
|
||||
async createWorkbenchHandoff(
|
||||
_workspaceId: string,
|
||||
input: HandoffWorkbenchCreateHandoffInput,
|
||||
): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
||||
return await workbench.createHandoff(input);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.markHandoffUnread(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchHandoff(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
await workbench.renameHandoff(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
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<void> {
|
||||
await workbench.renameSession(input);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(
|
||||
_workspaceId: string,
|
||||
input: HandoffWorkbenchSetSessionUnreadInput,
|
||||
): Promise<void> {
|
||||
await workbench.setSessionUnread(input);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(_workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await workbench.updateDraft(input);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(_workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
await workbench.changeModel(input);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(_workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await workbench.sendMessage(input);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await workbench.stopAgent(input);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await workbench.closeTab(input);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.publishPr(input);
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(_workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void> {
|
||||
await workbench.revertFile(input);
|
||||
},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
|
||||
return { workspaceId };
|
||||
},
|
||||
|
||||
async starSandboxAgentRepo(): Promise<StarSandboxAgentRepoResult> {
|
||||
return {
|
||||
repo: "rivet-dev/sandbox-agent",
|
||||
starredAt: nowMs(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -546,6 +558,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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={railRef}
|
||||
className={css({
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "#090607",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<RightSidebar
|
||||
handoff={handoff}
|
||||
activeTabId={activeTabId}
|
||||
onOpenDiff={onOpenDiff}
|
||||
onArchive={onArchive}
|
||||
onRevertFile={onRevertFile}
|
||||
onPublishPr={onPublishPr}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize terminal panel"
|
||||
onPointerDown={startResize}
|
||||
className={css({
|
||||
height: `${RIGHT_RAIL_SPLITTER_HEIGHT}px`,
|
||||
flexShrink: 0,
|
||||
cursor: "ns-resize",
|
||||
position: "relative",
|
||||
backgroundColor: "#050505",
|
||||
":before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: "42px",
|
||||
height: "4px",
|
||||
borderRadius: "999px",
|
||||
transform: "translate(-50%, -50%)",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.14)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
height: `${terminalHeight}px`,
|
||||
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
|
||||
backgroundColor: "#080506",
|
||||
overflow: "hidden",
|
||||
})}
|
||||
>
|
||||
<TerminalPane workspaceId={workspaceId} handoffId={handoff.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface MockLayoutProps {
|
||||
workspaceId: string;
|
||||
selectedHandoffId?: string | null;
|
||||
|
|
@ -1032,7 +1195,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
|||
setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths }));
|
||||
}}
|
||||
/>
|
||||
<RightSidebar
|
||||
<RightRail
|
||||
workspaceId={workspaceId}
|
||||
handoff={activeHandoff}
|
||||
activeTabId={activeTabId}
|
||||
onOpenDiff={openDiffTab}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,8 @@
|
|||
import { createBackendClient } from "@openhandoff/client";
|
||||
import { backendEndpoint, defaultWorkspaceId } from "./env";
|
||||
import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env";
|
||||
|
||||
export const backendClient = createBackendClient({
|
||||
endpoint: backendEndpoint,
|
||||
defaultWorkspaceId,
|
||||
mode: frontendClientMode,
|
||||
});
|
||||
|
|
|
|||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -510,6 +510,9 @@ importers:
|
|||
rivetkit:
|
||||
specifier: 2.1.6
|
||||
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260310.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0)
|
||||
sandbox-agent:
|
||||
specifier: workspace:*
|
||||
version: link:../../../sdks/typescript
|
||||
devDependencies:
|
||||
tsup:
|
||||
specifier: ^8.5.0
|
||||
|
|
@ -547,6 +550,9 @@ importers:
|
|||
react-dom:
|
||||
specifier: ^19.1.1
|
||||
version: 19.2.4(react@19.2.4)
|
||||
sandbox-agent:
|
||||
specifier: workspace:*
|
||||
version: link:../../../sdks/typescript
|
||||
styletron-engine-atomic:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SandboxAgent, TerminalErrorStatus, TerminalExitStatus } from "sandbox-agent";
|
||||
import type {
|
||||
SandboxAgent,
|
||||
TerminalErrorStatus,
|
||||
TerminalExitStatus,
|
||||
TerminalReadyStatus,
|
||||
} from "sandbox-agent";
|
||||
|
||||
type ConnectionState = "connecting" | "ready" | "closed" | "error";
|
||||
|
||||
|
|
@ -15,7 +20,9 @@ export interface ProcessTerminalProps {
|
|||
className?: string;
|
||||
style?: CSSProperties;
|
||||
terminalStyle?: CSSProperties;
|
||||
statusBarStyleOverride?: CSSProperties;
|
||||
height?: number | string;
|
||||
showStatusBar?: boolean;
|
||||
onExit?: (status: TerminalExitStatus) => void;
|
||||
onError?: (error: TerminalErrorStatus | Error) => void;
|
||||
}
|
||||
|
|
@ -90,7 +97,18 @@ const getStatusColor = (state: ConnectionState): string => {
|
|||
}
|
||||
};
|
||||
|
||||
export const ProcessTerminal = ({ client, processId, className, style, terminalStyle, height = 360, onExit, onError }: ProcessTerminalProps) => {
|
||||
export const ProcessTerminal = ({
|
||||
client,
|
||||
processId,
|
||||
className,
|
||||
style,
|
||||
terminalStyle,
|
||||
statusBarStyleOverride,
|
||||
height = 360,
|
||||
showStatusBar = true,
|
||||
onExit,
|
||||
onError,
|
||||
}: ProcessTerminalProps) => {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
||||
const [statusMessage, setStatusMessage] = useState("Connecting to PTY...");
|
||||
|
|
@ -165,7 +183,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
|||
const nextSession = client.connectProcessTerminal(processId);
|
||||
session = nextSession;
|
||||
|
||||
nextSession.onReady((frame) => {
|
||||
nextSession.onReady((frame: TerminalReadyStatus) => {
|
||||
if (cancelled || frame.type !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -175,14 +193,14 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
|||
syncSize();
|
||||
});
|
||||
|
||||
nextSession.onData((bytes) => {
|
||||
nextSession.onData((bytes: Uint8Array) => {
|
||||
if (cancelled || !terminal) {
|
||||
return;
|
||||
}
|
||||
terminal.write(bytes);
|
||||
});
|
||||
|
||||
nextSession.onExit((frame) => {
|
||||
nextSession.onExit((frame: TerminalExitStatus) => {
|
||||
if (cancelled || frame.type !== "exit") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -193,7 +211,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
|||
onExit?.(frame);
|
||||
});
|
||||
|
||||
nextSession.onError((error) => {
|
||||
nextSession.onError((error: TerminalErrorStatus | Error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -239,10 +257,12 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
|||
|
||||
return (
|
||||
<div className={className} style={{ ...shellStyle, ...style }}>
|
||||
<div style={statusBarStyle}>
|
||||
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
||||
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
|
||||
</div>
|
||||
{showStatusBar ? (
|
||||
<div style={{ ...statusBarStyle, ...statusBarStyleOverride }}>
|
||||
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
||||
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={hostRef}
|
||||
role="presentation"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue