Add foundry terminal and process pane

This commit is contained in:
Nathan Flurry 2026-03-10 23:55:43 -07:00
parent 0471214d65
commit 28c4ac22ff
16 changed files with 2412 additions and 36 deletions

View file

@ -10,6 +10,7 @@ import {
initCreateSessionActivity,
initEnsureAgentActivity,
initEnsureNameActivity,
initExposeSandboxActivity,
initFailedActivity,
initStartSandboxInstanceActivity,
initStartStatusSyncActivity,
@ -93,6 +94,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,

View file

@ -419,6 +419,62 @@ export async function initCreateSessionActivity(
});
}
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,

View file

@ -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;
@ -79,7 +92,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 })
@ -101,7 +114,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,
@ -116,7 +129,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({
@ -125,7 +138,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.
@ -282,6 +295,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 = {
@ -478,6 +498,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();

View file

@ -5,7 +5,18 @@ import type {
SandboxAgentClientOptions,
SandboxSessionCreateRequest
} from "./integrations/sandbox-agent/client.js";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
import type {
DaytonaClientOptions,
DaytonaCreateSandboxOptions,
@ -80,6 +91,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>;

View file

@ -3,6 +3,11 @@ import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionPersistDriver,
SessionRecord
@ -213,6 +218,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);

View file

@ -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 {
@ -74,6 +82,25 @@ export function createTestSandboxAgentDriver(
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" }),
@ -85,6 +112,12 @@ export function createTestSandboxAgentClient(
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) => {},