mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 00:02:48 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
|
|
@ -0,0 +1,5 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const sandboxInstanceDb = db({ schema, migrations });
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/sandbox-instance/db/drizzle",
|
||||
schema: "./src/actors/sandbox-instance/db/schema.ts",
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE `sandbox_instance` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`metadata_json` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE `sandbox_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent` text NOT NULL,
|
||||
`agent_session_id` text NOT NULL,
|
||||
`last_connection_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`destroyed_at` integer,
|
||||
`session_init_json` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `sandbox_session_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
`event_index` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`connection_id` text NOT NULL,
|
||||
`sender` text NOT NULL,
|
||||
`payload_json` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX `sandbox_sessions_created_at_idx` ON `sandbox_sessions` (`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sandbox_session_events_session_id_event_index_idx` ON `sandbox_session_events` (`session_id`,`event_index`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sandbox_session_events_session_id_created_at_idx` ON `sandbox_session_events` (`session_id`,`created_at`);
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ef8a919c-64f0-46d9-b8ed-a15f039e6ba7",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"sandbox_instance": {
|
||||
"name": "sandbox_instance",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"metadata_json": {
|
||||
"name": "metadata_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924375604,
|
||||
"tag": "0000_broad_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1776482400000,
|
||||
"tag": "0001_sandbox_sessions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// This file is generated by src/actors/_scripts/generate-actor-migrations.ts.
|
||||
// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql).
|
||||
// Do not hand-edit this file.
|
||||
|
||||
const journal = {
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924375604,
|
||||
tag: "0000_broad_tyrannus",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1776482400000,
|
||||
tag: "0001_sandbox_sessions",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`sandbox_instance\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`metadata_json\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `CREATE TABLE \`sandbox_sessions\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`agent\` text NOT NULL,
|
||||
\`agent_session_id\` text NOT NULL,
|
||||
\`last_connection_id\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`destroyed_at\` integer,
|
||||
\`session_init_json\` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`sandbox_session_events\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`event_index\` integer NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`connection_id\` text NOT NULL,
|
||||
\`sender\` text NOT NULL,
|
||||
\`payload_json\` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX \`sandbox_sessions_created_at_idx\` ON \`sandbox_sessions\` (\`created_at\`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per sandbox-instance actor instance.
|
||||
export const sandboxInstance = sqliteTable("sandbox_instance", {
|
||||
id: integer("id").primaryKey(),
|
||||
metadataJson: text("metadata_json").notNull(),
|
||||
status: text("status").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// Persist sandbox-agent sessions/events in SQLite instead of actor state so they survive
|
||||
// serverless actor evictions and backend restarts.
|
||||
export const sandboxSessions = sqliteTable("sandbox_sessions", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
agent: text("agent").notNull(),
|
||||
agentSessionId: text("agent_session_id").notNull(),
|
||||
lastConnectionId: text("last_connection_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
destroyedAt: integer("destroyed_at"),
|
||||
sessionInitJson: text("session_init_json"),
|
||||
});
|
||||
|
||||
export const sandboxSessionEvents = sqliteTable("sandbox_session_events", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
sessionId: text("session_id").notNull(),
|
||||
eventIndex: integer("event_index").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
connectionId: text("connection_id").notNull(),
|
||||
sender: text("sender").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
});
|
||||
636
foundry/packages/backend/src/actors/sandbox-instance/index.ts
Normal file
636
foundry/packages/backend/src/actors/sandbox-instance/index.ts
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { actor, queue } from "rivetkit";
|
||||
import { Loop, workflow } from "rivetkit/workflow";
|
||||
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||
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";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { selfSandboxInstance } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
|
||||
export interface SandboxInstanceInput {
|
||||
workspaceId: string;
|
||||
providerId: ProviderId;
|
||||
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;
|
||||
const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
function normalizeStatusFromEventPayload(payload: unknown): "running" | "idle" | "error" | null {
|
||||
if (payload && typeof payload === "object") {
|
||||
const envelope = payload as {
|
||||
error?: unknown;
|
||||
method?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
if (envelope.error) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
if (envelope.result && typeof envelope.result === "object") {
|
||||
const stopReason = (envelope.result as { stopReason?: unknown }).stopReason;
|
||||
if (typeof stopReason === "string" && stopReason.length > 0) {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof envelope.method === "string") {
|
||||
const lowered = envelope.method.toLowerCase();
|
||||
if (lowered.includes("error") || lowered.includes("failed")) {
|
||||
return "error";
|
||||
}
|
||||
if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, item) => {
|
||||
if (typeof item === "bigint") return item.toString();
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(metadataJson) as unknown;
|
||||
if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPersistedAgentConfig(c: any): Promise<SandboxAgentConnection | null> {
|
||||
try {
|
||||
const row = await c.db
|
||||
.select({ metadataJson: sandboxInstanceTable.metadataJson })
|
||||
.from(sandboxInstanceTable)
|
||||
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (row?.metadataJson) {
|
||||
const metadata = parseMetadata(row.metadataJson);
|
||||
const endpoint = typeof metadata.agentEndpoint === "string" ? metadata.agentEndpoint.trim() : "";
|
||||
const token = typeof metadata.agentToken === "string" ? metadata.agentToken.trim() : "";
|
||||
if (endpoint) {
|
||||
return token ? { endpoint, token } : { endpoint };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadFreshDaytonaAgentConfig(c: any): Promise<SandboxAgentConnection> {
|
||||
const { config, driver } = getActorRuntimeContext();
|
||||
const daytona = driver.daytona.createClient({
|
||||
apiUrl: config.providers.daytona.endpoint,
|
||||
apiKey: config.providers.daytona.apiKey,
|
||||
});
|
||||
const sandbox = await daytona.getSandbox(c.state.sandboxId);
|
||||
const state = String(sandbox.state ?? "unknown").toLowerCase();
|
||||
if (state !== "started" && state !== "running") {
|
||||
await daytona.startSandbox(c.state.sandboxId, 60);
|
||||
}
|
||||
const preview = await daytona.getPreviewEndpoint(c.state.sandboxId, 2468);
|
||||
return preview.token ? { endpoint: preview.url, token: preview.token } : { endpoint: preview.url };
|
||||
}
|
||||
|
||||
async function loadFreshProviderAgentConfig(c: any): Promise<SandboxAgentConnection> {
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(c.state.providerId);
|
||||
return await provider.ensureSandboxAgent({
|
||||
workspaceId: c.state.workspaceId,
|
||||
sandboxId: c.state.sandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
// Rotating preview URLs on every call fragments SDK client state (sessions/events)
|
||||
// because client caching keys by endpoint.
|
||||
if (persisted) {
|
||||
return persisted;
|
||||
}
|
||||
return await loadFreshDaytonaAgentConfig(c);
|
||||
}
|
||||
|
||||
// Local sandboxes are tied to the current backend process, so the sandbox-agent
|
||||
// token can rotate on restart. Always refresh from the provider instead of
|
||||
// trusting persisted metadata.
|
||||
if (c.state.providerId === "local") {
|
||||
return await loadFreshProviderAgentConfig(c);
|
||||
}
|
||||
|
||||
if (persisted) {
|
||||
return persisted;
|
||||
}
|
||||
|
||||
return await loadFreshProviderAgentConfig(c);
|
||||
}
|
||||
|
||||
async function derivePersistedSessionStatus(
|
||||
persist: SandboxInstancePersistDriver,
|
||||
sessionId: string,
|
||||
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
const session = await persist.getSession(sessionId);
|
||||
if (!session) {
|
||||
return { id: sessionId, status: "error" };
|
||||
}
|
||||
|
||||
if (session.destroyedAt) {
|
||||
return { id: sessionId, status: "idle" };
|
||||
}
|
||||
|
||||
const events = await persist.listEvents({
|
||||
sessionId,
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
for (let index = events.items.length - 1; index >= 0; index -= 1) {
|
||||
const event = events.items[index];
|
||||
if (!event) continue;
|
||||
const status = normalizeStatusFromEventPayload(event.payload);
|
||||
if (status) {
|
||||
return { id: sessionId, status };
|
||||
}
|
||||
}
|
||||
|
||||
return { id: sessionId, status: "idle" };
|
||||
}
|
||||
|
||||
function isTransientSessionCreateError(detail: string): boolean {
|
||||
const lowered = detail.toLowerCase();
|
||||
if (lowered.includes("timed out") || lowered.includes("timeout") || lowered.includes("504") || lowered.includes("gateway timeout")) {
|
||||
// ACP timeout errors are expensive and usually deterministic for the same
|
||||
// request; immediate retries spawn additional sessions/processes and make
|
||||
// recovery harder.
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
lowered.includes("502") || lowered.includes("503") || lowered.includes("bad gateway") || lowered.includes("econnreset") || lowered.includes("econnrefused")
|
||||
);
|
||||
}
|
||||
|
||||
interface EnsureSandboxCommand {
|
||||
metadata: Record<string, unknown>;
|
||||
status: string;
|
||||
agentEndpoint?: string;
|
||||
agentToken?: string;
|
||||
}
|
||||
|
||||
interface HealthSandboxCommand {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CreateSessionCommand {
|
||||
prompt: string;
|
||||
cwd?: string;
|
||||
agent?: "claude" | "codex" | "opencode";
|
||||
}
|
||||
|
||||
interface CreateSessionResult {
|
||||
id: string | null;
|
||||
status: "running" | "idle" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ListSessionsCommand {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface ListSessionEventsCommand {
|
||||
sessionId: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface SendPromptCommand {
|
||||
sessionId: string;
|
||||
prompt: string;
|
||||
notification?: boolean;
|
||||
}
|
||||
|
||||
interface SessionStatusCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface SessionControlCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const SANDBOX_INSTANCE_QUEUE_NAMES = [
|
||||
"sandboxInstance.command.ensure",
|
||||
"sandboxInstance.command.updateHealth",
|
||||
"sandboxInstance.command.destroy",
|
||||
"sandboxInstance.command.createSession",
|
||||
"sandboxInstance.command.sendPrompt",
|
||||
"sandboxInstance.command.cancelSession",
|
||||
"sandboxInstance.command.destroySession",
|
||||
] as const;
|
||||
|
||||
type SandboxInstanceQueueName = (typeof SANDBOX_INSTANCE_QUEUE_NAMES)[number];
|
||||
|
||||
function sandboxInstanceWorkflowQueueName(name: SandboxInstanceQueueName): SandboxInstanceQueueName {
|
||||
return name;
|
||||
}
|
||||
|
||||
async function getSandboxAgentClient(c: any) {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const persist = new SandboxInstancePersistDriver(c.db);
|
||||
const { endpoint, token } = await loadAgentConfig(c);
|
||||
return driver.sandboxAgent.createClient({
|
||||
endpoint,
|
||||
token,
|
||||
persist,
|
||||
});
|
||||
}
|
||||
|
||||
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 = {
|
||||
...command.metadata,
|
||||
agentEndpoint: command.agentEndpoint ?? null,
|
||||
agentToken: command.agentToken ?? null,
|
||||
};
|
||||
|
||||
const metadataJson = stringifyJson(metadata);
|
||||
await c.db
|
||||
.insert(sandboxInstanceTable)
|
||||
.values({
|
||||
id: SANDBOX_ROW_ID,
|
||||
metadataJson,
|
||||
status: command.status,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sandboxInstanceTable.id,
|
||||
set: {
|
||||
metadataJson,
|
||||
status: command.status,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function updateHealthMutation(c: any, command: HealthSandboxCommand): Promise<void> {
|
||||
await c.db
|
||||
.update(sandboxInstanceTable)
|
||||
.set({
|
||||
status: `${command.status}:${command.message}`,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
async function destroySandboxMutation(c: any): Promise<void> {
|
||||
await c.db.delete(sandboxInstanceTable).where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)).run();
|
||||
}
|
||||
|
||||
async function createSessionMutation(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
|
||||
let lastDetail = "sandbox-agent createSession failed";
|
||||
let attemptsMade = 0;
|
||||
|
||||
for (let attempt = 1; attempt <= CREATE_SESSION_MAX_ATTEMPTS; attempt += 1) {
|
||||
attemptsMade = attempt;
|
||||
try {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
|
||||
const session = await client.createSession({
|
||||
prompt: command.prompt,
|
||||
cwd: command.cwd,
|
||||
agent: command.agent,
|
||||
});
|
||||
|
||||
return { id: session.id, status: session.status };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
lastDetail = detail;
|
||||
const retryable = isTransientSessionCreateError(detail);
|
||||
const canRetry = retryable && attempt < CREATE_SESSION_MAX_ATTEMPTS;
|
||||
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
const waitMs = CREATE_SESSION_RETRY_BASE_MS * attempt;
|
||||
logActorWarning("sandbox-instance", "createSession transient failure; retrying", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: c.state.sandboxId,
|
||||
attempt,
|
||||
maxAttempts: CREATE_SESSION_MAX_ATTEMPTS,
|
||||
waitMs,
|
||||
error: detail,
|
||||
});
|
||||
await delay(waitMs);
|
||||
}
|
||||
}
|
||||
|
||||
const attemptLabel = attemptsMade === 1 ? "attempt" : "attempts";
|
||||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: `sandbox-agent createSession failed after ${attemptsMade} ${attemptLabel}: ${lastDetail}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendPromptMutation(c: any, command: SendPromptCommand): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
await client.sendPrompt({
|
||||
sessionId: command.sessionId,
|
||||
prompt: command.prompt,
|
||||
notification: command.notification,
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelSessionMutation(c: any, command: SessionControlCommand): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
await client.cancelSession(command.sessionId);
|
||||
}
|
||||
|
||||
async function destroySessionMutation(c: any, command: SessionControlCommand): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
await client.destroySession(command.sessionId);
|
||||
}
|
||||
|
||||
async function runSandboxInstanceWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("sandbox-instance-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-sandbox-instance-command", {
|
||||
names: [...SANDBOX_INSTANCE_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.ensure") {
|
||||
await loopCtx.step("sandbox-instance-ensure", async () => ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.updateHealth") {
|
||||
await loopCtx.step("sandbox-instance-update-health", async () => updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.destroy") {
|
||||
await loopCtx.step("sandbox-instance-destroy", async () => destroySandboxMutation(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.createSession") {
|
||||
const result = await loopCtx.step({
|
||||
name: "sandbox-instance-create-session",
|
||||
timeout: CREATE_SESSION_STEP_TIMEOUT_MS,
|
||||
run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.sendPrompt") {
|
||||
await loopCtx.step("sandbox-instance-send-prompt", async () => sendPromptMutation(loopCtx, msg.body as SendPromptCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.cancelSession") {
|
||||
await loopCtx.step("sandbox-instance-cancel-session", async () => cancelSessionMutation(loopCtx, msg.body as SessionControlCommand));
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "sandboxInstance.command.destroySession") {
|
||||
await loopCtx.step("sandbox-instance-destroy-session", async () => destroySessionMutation(loopCtx, msg.body as SessionControlCommand));
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export const sandboxInstance = actor({
|
||||
db: sandboxInstanceDb,
|
||||
queues: Object.fromEntries(SANDBOX_INSTANCE_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: SandboxInstanceInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
providerId: input.providerId,
|
||||
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();
|
||||
|
||||
if (c.state.providerId === "daytona") {
|
||||
const daytona = driver.daytona.createClient({
|
||||
apiUrl: config.providers.daytona.endpoint,
|
||||
apiKey: config.providers.daytona.apiKey,
|
||||
});
|
||||
const sandbox = await daytona.getSandbox(c.state.sandboxId);
|
||||
const state = String(sandbox.state ?? "unknown").toLowerCase();
|
||||
return { providerId: c.state.providerId, sandboxId: c.state.sandboxId, state, at };
|
||||
}
|
||||
|
||||
return {
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: c.state.sandboxId,
|
||||
state: "unknown",
|
||||
at,
|
||||
};
|
||||
},
|
||||
|
||||
async ensure(c, command: EnsureSandboxCommand): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.ensure"), command, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async updateHealth(c, command: HealthSandboxCommand): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.updateHealth"), command, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async destroy(c): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(
|
||||
sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async createSession(c: any, command: CreateSessionCommand): Promise<CreateSessionResult> {
|
||||
const self = selfSandboxInstance(c);
|
||||
return expectQueueResponse<CreateSessionResult>(
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.createSession"), command, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async listSessions(c: any, command?: ListSessionsCommand): Promise<{ items: SessionRecord[]; nextCursor?: string }> {
|
||||
const persist = new SandboxInstancePersistDriver(c.db);
|
||||
try {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
|
||||
const page = await client.listSessions({
|
||||
cursor: command?.cursor,
|
||||
limit: command?.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
items: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
logActorWarning("sandbox-instance", "listSessions remote read failed; using persisted fallback", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: c.state.sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
return await persist.listSessions({
|
||||
cursor: command?.cursor,
|
||||
limit: command?.limit,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async listSessionEvents(c: any, command: ListSessionEventsCommand): Promise<{ items: SessionEvent[]; nextCursor?: string }> {
|
||||
const persist = new SandboxInstancePersistDriver(c.db);
|
||||
return await persist.listEvents({
|
||||
sessionId: command.sessionId,
|
||||
cursor: command.cursor,
|
||||
limit: command.limit,
|
||||
});
|
||||
},
|
||||
|
||||
async sendPrompt(c, command: SendPromptCommand): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.sendPrompt"), command, {
|
||||
wait: true,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async cancelSession(c, command: SessionControlCommand): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.cancelSession"), command, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async destroySession(c, command: SessionControlCommand): Promise<void> {
|
||||
const self = selfSandboxInstance(c);
|
||||
await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroySession"), command, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
});
|
||||
},
|
||||
|
||||
async sessionStatus(c, command: SessionStatusCommand): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
return await derivePersistedSessionStatus(new SandboxInstancePersistDriver(c.db), command.sessionId);
|
||||
},
|
||||
},
|
||||
run: workflow(runSandboxInstanceWorkflow),
|
||||
});
|
||||
266
foundry/packages/backend/src/actors/sandbox-instance/persist.ts
Normal file
266
foundry/packages/backend/src/actors/sandbox-instance/persist.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { and, asc, count, eq } from "drizzle-orm";
|
||||
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
|
||||
import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js";
|
||||
|
||||
const DEFAULT_MAX_SESSIONS = 1024;
|
||||
const DEFAULT_MAX_EVENTS_PER_SESSION = 500;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
|
||||
function normalizeCap(value: number | undefined, fallback: number): number {
|
||||
if (!Number.isFinite(value) || (value ?? 0) < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(value as number);
|
||||
}
|
||||
|
||||
function parseCursor(cursor: string | undefined): number {
|
||||
if (!cursor) return 0;
|
||||
const parsed = Number.parseInt(cursor, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveEventListOffset(params: { cursor?: string; total: number; limit: number }): number {
|
||||
if (params.cursor != null) {
|
||||
return parseCursor(params.cursor);
|
||||
}
|
||||
return Math.max(0, params.total - params.limit);
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, item) => {
|
||||
if (typeof item === "bigint") return item.toString();
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function safeParseJson<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SandboxInstancePersistDriverOptions {
|
||||
maxSessions?: number;
|
||||
maxEventsPerSession?: number;
|
||||
}
|
||||
|
||||
export class SandboxInstancePersistDriver implements SessionPersistDriver {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEventsPerSession: number;
|
||||
|
||||
constructor(
|
||||
private readonly db: any,
|
||||
options: SandboxInstancePersistDriverOptions = {},
|
||||
) {
|
||||
this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
|
||||
this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<SessionRecord | null> {
|
||||
const row = await this.db
|
||||
.select({
|
||||
id: sandboxSessions.id,
|
||||
agent: sandboxSessions.agent,
|
||||
agentSessionId: sandboxSessions.agentSessionId,
|
||||
lastConnectionId: sandboxSessions.lastConnectionId,
|
||||
createdAt: sandboxSessions.createdAt,
|
||||
destroyedAt: sandboxSessions.destroyedAt,
|
||||
sessionInitJson: sandboxSessions.sessionInitJson,
|
||||
})
|
||||
.from(sandboxSessions)
|
||||
.where(eq(sandboxSessions.id, id))
|
||||
.get();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
agent: row.agent,
|
||||
agentSessionId: row.agentSessionId,
|
||||
lastConnectionId: row.lastConnectionId,
|
||||
createdAt: row.createdAt,
|
||||
destroyedAt: row.destroyedAt ?? undefined,
|
||||
sessionInit: safeParseJson(row.sessionInitJson, undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
|
||||
const offset = parseCursor(request.cursor);
|
||||
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: sandboxSessions.id,
|
||||
agent: sandboxSessions.agent,
|
||||
agentSessionId: sandboxSessions.agentSessionId,
|
||||
lastConnectionId: sandboxSessions.lastConnectionId,
|
||||
createdAt: sandboxSessions.createdAt,
|
||||
destroyedAt: sandboxSessions.destroyedAt,
|
||||
sessionInitJson: sandboxSessions.sessionInitJson,
|
||||
})
|
||||
.from(sandboxSessions)
|
||||
.orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
id: row.id,
|
||||
agent: row.agent,
|
||||
agentSessionId: row.agentSessionId,
|
||||
lastConnectionId: row.lastConnectionId,
|
||||
createdAt: row.createdAt,
|
||||
destroyedAt: row.destroyedAt ?? undefined,
|
||||
sessionInit: safeParseJson(row.sessionInitJson, undefined),
|
||||
}));
|
||||
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
|
||||
const nextOffset = offset + items.length;
|
||||
return {
|
||||
items,
|
||||
nextCursor: nextOffset < total ? String(nextOffset) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSession(session: SessionRecord): Promise<void> {
|
||||
const now = Date.now();
|
||||
await this.db
|
||||
.insert(sandboxSessions)
|
||||
.values({
|
||||
id: session.id,
|
||||
agent: session.agent,
|
||||
agentSessionId: session.agentSessionId,
|
||||
lastConnectionId: session.lastConnectionId,
|
||||
createdAt: session.createdAt ?? now,
|
||||
destroyedAt: session.destroyedAt ?? null,
|
||||
sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sandboxSessions.id,
|
||||
set: {
|
||||
agent: session.agent,
|
||||
agentSessionId: session.agentSessionId,
|
||||
lastConnectionId: session.lastConnectionId,
|
||||
createdAt: session.createdAt ?? now,
|
||||
destroyedAt: session.destroyedAt ?? null,
|
||||
sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
// Evict oldest sessions beyond cap.
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessions).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const overflow = total - this.maxSessions;
|
||||
if (overflow <= 0) return;
|
||||
|
||||
const toRemove = await this.db
|
||||
.select({ id: sandboxSessions.id })
|
||||
.from(sandboxSessions)
|
||||
.orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id))
|
||||
.limit(overflow)
|
||||
.all();
|
||||
|
||||
for (const row of toRemove) {
|
||||
await this.db.delete(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, row.id)).run();
|
||||
await this.db.delete(sandboxSessions).where(eq(sandboxSessions.id, row.id)).run();
|
||||
}
|
||||
}
|
||||
|
||||
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
|
||||
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, request.sessionId)).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const offset = resolveEventListOffset({
|
||||
cursor: request.cursor,
|
||||
total,
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: sandboxSessionEvents.id,
|
||||
sessionId: sandboxSessionEvents.sessionId,
|
||||
eventIndex: sandboxSessionEvents.eventIndex,
|
||||
createdAt: sandboxSessionEvents.createdAt,
|
||||
connectionId: sandboxSessionEvents.connectionId,
|
||||
sender: sandboxSessionEvents.sender,
|
||||
payloadJson: sandboxSessionEvents.payloadJson,
|
||||
})
|
||||
.from(sandboxSessionEvents)
|
||||
.where(eq(sandboxSessionEvents.sessionId, request.sessionId))
|
||||
.orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const items: SessionEvent[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
eventIndex: row.eventIndex,
|
||||
sessionId: row.sessionId,
|
||||
createdAt: row.createdAt,
|
||||
connectionId: row.connectionId,
|
||||
sender: row.sender as any,
|
||||
payload: safeParseJson(row.payloadJson, null),
|
||||
}));
|
||||
|
||||
const nextOffset = offset + items.length;
|
||||
return {
|
||||
items,
|
||||
nextCursor: nextOffset < total ? String(nextOffset) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async insertEvent(event: SessionEvent): Promise<void> {
|
||||
await this.db
|
||||
.insert(sandboxSessionEvents)
|
||||
.values({
|
||||
id: event.id,
|
||||
sessionId: event.sessionId,
|
||||
eventIndex: event.eventIndex,
|
||||
createdAt: event.createdAt,
|
||||
connectionId: event.connectionId,
|
||||
sender: event.sender,
|
||||
payloadJson: safeStringify(event.payload),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sandboxSessionEvents.id,
|
||||
set: {
|
||||
sessionId: event.sessionId,
|
||||
eventIndex: event.eventIndex,
|
||||
createdAt: event.createdAt,
|
||||
connectionId: event.connectionId,
|
||||
sender: event.sender,
|
||||
payloadJson: safeStringify(event.payload),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
// Trim oldest events beyond cap.
|
||||
const totalRow = await this.db.select({ c: count() }).from(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, event.sessionId)).get();
|
||||
const total = Number(totalRow?.c ?? 0);
|
||||
const overflow = total - this.maxEventsPerSession;
|
||||
if (overflow <= 0) return;
|
||||
|
||||
const toRemove = await this.db
|
||||
.select({ id: sandboxSessionEvents.id })
|
||||
.from(sandboxSessionEvents)
|
||||
.where(eq(sandboxSessionEvents.sessionId, event.sessionId))
|
||||
.orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id))
|
||||
.limit(overflow)
|
||||
.all();
|
||||
|
||||
for (const row of toRemove) {
|
||||
await this.db
|
||||
.delete(sandboxSessionEvents)
|
||||
.where(and(eq(sandboxSessionEvents.sessionId, event.sessionId), eq(sandboxSessionEvents.id, row.id)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue