mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 04:02:25 +00:00
chore(foundry): improve sandbox impl + status pill (#252)
* Improve Daytona sandbox provisioning and frontend UI Refactor git clone script in Daytona provider to use cleaner shell logic for GitHub token authentication and branch checkout. Add support for private repository clones with token-based auth. Improve Daytona provider error handling and git configuration setup. Frontend improvements include enhanced dev panel, workspace dashboard, sidebar navigation, and UI components for better task/session management. Update interest manager and backend client to support improved session state handling. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * Add header status pill showing task/session/sandbox state Surface aggregate status (error, provisioning, running, ready, no sandbox) as a colored pill in the transcript panel header. Integrates task runtime status, session status, and sandbox availability via the sandboxProcesses interest topic so the pill accurately reflects unreachable sandboxes. Includes mock tasks demonstrating error, provisioning, and running states, unit tests for deriveHeaderStatus, and workspace-dashboard integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5a1b32a271
commit
70d31f819c
82 changed files with 2625 additions and 4166 deletions
|
|
@ -1,24 +1,15 @@
|
|||
import type { AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendDriver } from "../driver.js";
|
||||
import type { NotificationService } from "../notifications/index.js";
|
||||
import type { ProviderRegistry } from "../providers/index.js";
|
||||
import type { AppShellServices } from "../services/app-shell-runtime.js";
|
||||
|
||||
let runtimeConfig: AppConfig | null = null;
|
||||
let providerRegistry: ProviderRegistry | null = null;
|
||||
let notificationService: NotificationService | null = null;
|
||||
let runtimeDriver: BackendDriver | null = null;
|
||||
let appShellServices: AppShellServices | null = null;
|
||||
|
||||
export function initActorRuntimeContext(
|
||||
config: AppConfig,
|
||||
providers: ProviderRegistry,
|
||||
notifications?: NotificationService,
|
||||
driver?: BackendDriver,
|
||||
appShell?: AppShellServices,
|
||||
): void {
|
||||
export function initActorRuntimeContext(config: AppConfig, notifications?: NotificationService, driver?: BackendDriver, appShell?: AppShellServices): void {
|
||||
runtimeConfig = config;
|
||||
providerRegistry = providers;
|
||||
notificationService = notifications ?? null;
|
||||
runtimeDriver = driver ?? null;
|
||||
appShellServices = appShell ?? null;
|
||||
|
|
@ -26,12 +17,11 @@ export function initActorRuntimeContext(
|
|||
|
||||
export function getActorRuntimeContext(): {
|
||||
config: AppConfig;
|
||||
providers: ProviderRegistry;
|
||||
notifications: NotificationService | null;
|
||||
driver: BackendDriver;
|
||||
appShell: AppShellServices;
|
||||
} {
|
||||
if (!runtimeConfig || !providerRegistry) {
|
||||
if (!runtimeConfig) {
|
||||
throw new Error("Actor runtime context not initialized");
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +35,6 @@ export function getActorRuntimeContext(): {
|
|||
|
||||
return {
|
||||
config: runtimeConfig,
|
||||
providers: providerRegistry,
|
||||
notifications: notificationService,
|
||||
driver: runtimeDriver,
|
||||
appShell: appShellServices,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,4 @@
|
|||
import {
|
||||
authUserKey,
|
||||
taskKey,
|
||||
taskStatusSyncKey,
|
||||
historyKey,
|
||||
projectBranchSyncKey,
|
||||
projectKey,
|
||||
projectPrSyncKey,
|
||||
sandboxInstanceKey,
|
||||
workspaceKey,
|
||||
} from "./keys.js";
|
||||
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||
import { authUserKey, taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
return c.client();
|
||||
|
|
@ -86,30 +75,12 @@ export async function getOrCreateProjectBranchSync(c: any, workspaceId: string,
|
|||
});
|
||||
}
|
||||
|
||||
export function getSandboxInstance(c: any, workspaceId: string, providerId: ProviderId, sandboxId: string) {
|
||||
return actorClient(c).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
|
||||
export function getTaskSandbox(c: any, workspaceId: string, sandboxId: string) {
|
||||
return actorClient(c).taskSandbox.get(taskSandboxKey(workspaceId, sandboxId));
|
||||
}
|
||||
|
||||
export async function getOrCreateSandboxInstance(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
createWithInput: Record<string, unknown>,
|
||||
) {
|
||||
return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput });
|
||||
}
|
||||
|
||||
export async function getOrCreateTaskStatusSync(
|
||||
c: any,
|
||||
workspaceId: string,
|
||||
repoId: string,
|
||||
taskId: string,
|
||||
sandboxId: string,
|
||||
sessionId: string,
|
||||
createWithInput: Record<string, unknown>,
|
||||
) {
|
||||
return await actorClient(c).taskStatusSync.getOrCreate(taskStatusSyncKey(workspaceId, repoId, taskId, sandboxId, sessionId), {
|
||||
export async function getOrCreateTaskSandbox(c: any, workspaceId: string, sandboxId: string, createWithInput?: Record<string, unknown>) {
|
||||
return await actorClient(c).taskSandbox.getOrCreate(taskSandboxKey(workspaceId, sandboxId), {
|
||||
createWithInput,
|
||||
});
|
||||
}
|
||||
|
|
@ -122,10 +93,6 @@ export function selfProjectBranchSync(c: any) {
|
|||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfTaskStatusSync(c: any) {
|
||||
return actorClient(c).taskStatusSync.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfHistory(c: any) {
|
||||
return actorClient(c).history.getForId(c.actorId);
|
||||
}
|
||||
|
|
@ -142,10 +109,6 @@ export function selfProject(c: any) {
|
|||
return actorClient(c).project.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfSandboxInstance(c: any) {
|
||||
return actorClient(c).sandboxInstance.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfAuthUser(c: any) {
|
||||
return actorClient(c).authUser.getForId(c.actorId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { authUser } from "./auth-user/index.js";
|
||||
import { setup } from "rivetkit";
|
||||
import { taskStatusSync } from "./task-status-sync/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
||||
import { project } from "./project/index.js";
|
||||
import { sandboxInstance } from "./sandbox-instance/index.js";
|
||||
import { taskSandbox } from "./sandbox/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
|
|
@ -27,23 +26,21 @@ export const registry = setup({
|
|||
workspace,
|
||||
project,
|
||||
task,
|
||||
sandboxInstance,
|
||||
taskSandbox,
|
||||
history,
|
||||
projectPrSync,
|
||||
projectBranchSync,
|
||||
taskStatusSync,
|
||||
},
|
||||
});
|
||||
|
||||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./auth-user/index.js";
|
||||
export * from "./task-status-sync/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./project-branch-sync/index.js";
|
||||
export * from "./project-pr-sync/index.js";
|
||||
export * from "./project/index.js";
|
||||
export * from "./sandbox-instance/index.js";
|
||||
export * from "./sandbox/index.js";
|
||||
export * from "./workspace/index.js";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ export function taskKey(workspaceId: string, repoId: string, taskId: string): Ac
|
|||
return ["ws", workspaceId, "project", repoId, "task", taskId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
export function taskSandboxKey(workspaceId: string, sandboxId: string): ActorKey {
|
||||
return ["ws", workspaceId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
||||
|
|
@ -31,8 +31,3 @@ export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey
|
|||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey {
|
||||
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
|
||||
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,12 +126,24 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
|||
}
|
||||
|
||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
||||
await prSync.start();
|
||||
|
||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||
await branchSync.start();
|
||||
|
||||
c.state.syncActorsStarted = true;
|
||||
|
||||
void prSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting pr sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
|
||||
void branchSync.start().catch((error: unknown) => {
|
||||
logActorWarning("project.sync", "starting branch sync actor failed", {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRepoActionJobsTable(c: any): Promise<void> {
|
||||
|
|
@ -316,13 +328,17 @@ async function ensureProjectReadyForRead(c: any): Promise<string> {
|
|||
throw new Error("project remoteUrl is not initialized");
|
||||
}
|
||||
|
||||
if (!c.state.localPath || !c.state.syncActorsStarted) {
|
||||
if (!c.state.localPath) {
|
||||
const result = await projectActions.ensure(c, { remoteUrl: c.state.remoteUrl });
|
||||
const localPath = result?.localPath ?? c.state.localPath;
|
||||
if (!localPath) {
|
||||
throw new Error("project local repo is not initialized");
|
||||
}
|
||||
return localPath;
|
||||
c.state.localPath = result?.localPath ?? c.state.localPath;
|
||||
}
|
||||
|
||||
if (!c.state.localPath) {
|
||||
throw new Error("project local repo is not initialized");
|
||||
}
|
||||
|
||||
if (!c.state.syncActorsStarted) {
|
||||
await ensureProjectSyncActors(c, c.state.localPath);
|
||||
}
|
||||
|
||||
return c.state.localPath;
|
||||
|
|
@ -428,7 +444,6 @@ async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise
|
|||
})
|
||||
.run();
|
||||
|
||||
await ensureProjectSyncActors(c, localPath);
|
||||
return { localPath };
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +452,6 @@ async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand):
|
|||
}
|
||||
|
||||
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||
const localPath = await ensureProjectReady(c);
|
||||
const onBranch = cmd.onBranch?.trim() || null;
|
||||
const initialBranchName = onBranch;
|
||||
const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null;
|
||||
|
|
@ -463,7 +477,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
|||
repoId: c.state.repoId,
|
||||
taskId,
|
||||
repoRemote: c.state.remoteUrl,
|
||||
repoLocalPath: localPath,
|
||||
branchName: initialBranchName,
|
||||
title: initialTitle,
|
||||
task: cmd.task,
|
||||
|
|
@ -565,24 +578,25 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand
|
|||
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
|
||||
const baseRef = await driver.git.remoteDefaultBaseRef(localPath);
|
||||
const normalizedBase = normalizeBaseBranchName(baseRef);
|
||||
let branchAvailableInRepo = false;
|
||||
|
||||
if (requireExistingRemote) {
|
||||
try {
|
||||
headSha = await driver.git.revParse(localPath, `origin/${branchName}`);
|
||||
branchAvailableInRepo = true;
|
||||
} catch {
|
||||
throw new Error(`Remote branch not found: ${branchName}`);
|
||||
}
|
||||
} else {
|
||||
await driver.git.ensureRemoteBranch(localPath, branchName, { githubToken: auth?.githubToken ?? null });
|
||||
await driver.git.fetch(localPath, { githubToken: auth?.githubToken ?? null });
|
||||
try {
|
||||
headSha = await driver.git.revParse(localPath, `origin/${branchName}`);
|
||||
branchAvailableInRepo = true;
|
||||
} catch {
|
||||
headSha = await driver.git.revParse(localPath, baseRef);
|
||||
}
|
||||
}
|
||||
|
||||
if (await driver.stack.available(localPath).catch(() => false)) {
|
||||
if (branchAvailableInRepo && (await driver.stack.available(localPath).catch(() => false))) {
|
||||
let stackRows = await driver.stack.listStack(localPath).catch(() => []);
|
||||
let stackRow = stackRows.find((entry) => entry.branchName === branchName);
|
||||
|
||||
|
|
@ -874,6 +888,10 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<vo
|
|||
|
||||
async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise<void> {
|
||||
const incoming = new Set(body.items.map((item) => item.branchName));
|
||||
const reservedRows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||
const reservedBranches = new Set(
|
||||
reservedRows.map((row) => row.branchName).filter((branchName): branchName is string => typeof branchName === "string" && branchName.length > 0),
|
||||
);
|
||||
|
||||
for (const item of body.items) {
|
||||
const existing = await c.db
|
||||
|
|
@ -918,7 +936,7 @@ async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Pr
|
|||
const existingRows = await c.db.select({ branchName: branches.branchName }).from(branches).all();
|
||||
|
||||
for (const row of existingRows) {
|
||||
if (incoming.has(row.branchName)) {
|
||||
if (incoming.has(row.branchName) || reservedBranches.has(row.branchName)) {
|
||||
continue;
|
||||
}
|
||||
await c.db.delete(branches).where(eq(branches.branchName, row.branchName)).run();
|
||||
|
|
@ -954,7 +972,7 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
|||
if (msg.name === "project.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "project-create-task",
|
||||
timeout: 12 * 60_000,
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
|
|
@ -1020,7 +1038,7 @@ export const projectActions = {
|
|||
return expectQueueResponse<TaskRecord>(
|
||||
await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, {
|
||||
wait: true,
|
||||
timeout: 12 * 60_000,
|
||||
timeout: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const sandboxInstanceDb = db({ schema, migrations });
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/sandbox-instance/db/drizzle",
|
||||
schema: "./src/actors/sandbox-instance/db/schema.ts",
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
CREATE TABLE `sandbox_instance` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`metadata_json` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> 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 UNIQUE INDEX `sandbox_session_events_session_id_event_index_unique` ON `sandbox_session_events` (`session_id`,`event_index`);--> statement-breakpoint
|
||||
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
|
||||
);
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "130486c5-6208-4d00-b367-e02b9def953a",
|
||||
"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": {}
|
||||
},
|
||||
"sandbox_session_events": {
|
||||
"name": "sandbox_session_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_index": {
|
||||
"name": "event_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"connection_id": {
|
||||
"name": "connection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender": {
|
||||
"name": "sender",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload_json": {
|
||||
"name": "payload_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_session_events_session_id_event_index_unique": {
|
||||
"name": "sandbox_session_events_session_id_event_index_unique",
|
||||
"columns": ["session_id", "event_index"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sandbox_sessions": {
|
||||
"name": "sandbox_sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent": {
|
||||
"name": "agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_connection_id": {
|
||||
"name": "last_connection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"destroyed_at": {
|
||||
"name": "destroyed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_init_json": {
|
||||
"name": "session_init_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773376224446,
|
||||
"tag": "0000_smooth_sauron",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// 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: 1773376224446,
|
||||
tag: "0000_smooth_sauron",
|
||||
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
|
||||
);
|
||||
--> 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 UNIQUE INDEX \`sandbox_session_events_session_id_event_index_unique\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`);--> statement-breakpoint
|
||||
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
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { integer, sqliteTable, text, uniqueIndex } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per sandbox-instance actor instance.
|
||||
export const sandboxInstance = sqliteTable("sandbox_instance", {
|
||||
id: integer("id").primaryKey(),
|
||||
// Structured by the provider/runtime metadata serializer for this actor.
|
||||
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"),
|
||||
// Structured by the sandbox-agent ACP session bootstrap payload.
|
||||
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(),
|
||||
// Structured by the sandbox-agent session event envelope.
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("sandbox_session_events_session_id_event_index_unique").on(table.sessionId, table.eventIndex)],
|
||||
);
|
||||
|
|
@ -1,640 +0,0 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
||||
async function broadcastProcessesUpdated(c: any): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const { processes } = await client.listProcesses();
|
||||
c.broadcast("processesUpdated", {
|
||||
type: "processesUpdated",
|
||||
processes,
|
||||
});
|
||||
}
|
||||
|
||||
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: {
|
||||
name: "Sandbox Instance",
|
||||
icon: "box",
|
||||
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);
|
||||
await 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);
|
||||
await broadcastProcessesUpdated(c);
|
||||
return stopped;
|
||||
},
|
||||
|
||||
async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
const killed = await client.killProcess(request.processId, request.query);
|
||||
await broadcastProcessesUpdated(c);
|
||||
return killed;
|
||||
},
|
||||
|
||||
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
|
||||
const client = await getSandboxAgentClient(c);
|
||||
await client.deleteProcess(request.processId);
|
||||
await 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),
|
||||
});
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
401
foundry/packages/backend/src/actors/sandbox/index.ts
Normal file
401
foundry/packages/backend/src/actors/sandbox/index.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { actor } from "rivetkit";
|
||||
import { e2b, sandboxActor } from "rivetkit/sandbox";
|
||||
import { existsSync } from "node:fs";
|
||||
import Dockerode from "dockerode";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { workspaceKey } from "../keys.js";
|
||||
import { resolveSandboxProviderId } from "../../sandbox-config.js";
|
||||
|
||||
const SANDBOX_REPO_CWD = "/home/sandbox/workspace/repo";
|
||||
const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:full";
|
||||
const DEFAULT_LOCAL_SANDBOX_PORT = 2468;
|
||||
const dockerClient = new Dockerode({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
function parseTaskSandboxKey(key: readonly string[]): { workspaceId: string; taskId: string } {
|
||||
if (key.length !== 4 || key[0] !== "ws" || key[2] !== "sandbox") {
|
||||
throw new Error(`Invalid task sandbox key: ${JSON.stringify(key)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: key[1]!,
|
||||
taskId: key[3]!,
|
||||
};
|
||||
}
|
||||
|
||||
function preferredDockerHost(): string {
|
||||
if (process.env.FOUNDRY_DOCKER_HOST?.trim()) {
|
||||
return process.env.FOUNDRY_DOCKER_HOST.trim();
|
||||
}
|
||||
|
||||
return existsSync("/.dockerenv") ? "host.docker.internal" : "127.0.0.1";
|
||||
}
|
||||
|
||||
function preferredPublicDockerHost(): string {
|
||||
if (process.env.FOUNDRY_PUBLIC_SANDBOX_HOST?.trim()) {
|
||||
return process.env.FOUNDRY_PUBLIC_SANDBOX_HOST.trim();
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
function localSandboxAgentPort(): number {
|
||||
const raw = process.env.FOUNDRY_LOCAL_SANDBOX_PORT?.trim() ?? process.env.HF_LOCAL_SANDBOX_PORT?.trim() ?? "";
|
||||
const parsed = Number(raw);
|
||||
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
||||
return parsed;
|
||||
}
|
||||
return DEFAULT_LOCAL_SANDBOX_PORT;
|
||||
}
|
||||
|
||||
function sandboxEnvPairs(): string[] {
|
||||
const openAiApiKey = process.env.OPENAI_API_KEY;
|
||||
const entries = [
|
||||
["ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY],
|
||||
["CLAUDE_API_KEY", process.env.CLAUDE_API_KEY ?? process.env.ANTHROPIC_API_KEY],
|
||||
["OPENAI_API_KEY", openAiApiKey],
|
||||
// Codex ACP prefers CODEX_API_KEY when present. In dev we want that to be the
|
||||
// actual OpenAI API key, not an unrelated local Codex auth token.
|
||||
["CODEX_API_KEY", openAiApiKey ?? process.env.CODEX_API_KEY],
|
||||
["GH_TOKEN", process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN],
|
||||
["GITHUB_TOKEN", process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN],
|
||||
["E2B_API_KEY", process.env.E2B_API_KEY],
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
}
|
||||
|
||||
function sandboxEnvObject(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
sandboxEnvPairs().map((entry) => {
|
||||
const [key, ...rest] = entry.split("=");
|
||||
return [key!, rest.join("=")];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function modeIdForAgent(agent?: string | null): string | null {
|
||||
switch (agent) {
|
||||
case "codex":
|
||||
return "full-access";
|
||||
case "claude":
|
||||
return "acceptEdits";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPublishedDockerPort(sandboxId: string, containerPort: number): Promise<number> {
|
||||
const info = await dockerClient.getContainer(sandboxId).inspect();
|
||||
const hostPort = info.NetworkSettings?.Ports?.[`${containerPort}/tcp`]?.[0]?.HostPort;
|
||||
if (!hostPort) {
|
||||
throw new Error(`docker sandbox-agent port ${containerPort} is not published`);
|
||||
}
|
||||
return Number(hostPort);
|
||||
}
|
||||
|
||||
function createLocalSandboxProvider(image: string): any {
|
||||
const agentPort = localSandboxAgentPort();
|
||||
const backendHost = preferredDockerHost();
|
||||
const publicHost = preferredPublicDockerHost();
|
||||
|
||||
return {
|
||||
name: "docker",
|
||||
|
||||
async create(_context: any): Promise<string> {
|
||||
const container = await dockerClient.createContainer({
|
||||
Image: image,
|
||||
Cmd: ["server", "--no-token", "--host", "0.0.0.0", "--port", String(agentPort)],
|
||||
Env: sandboxEnvPairs(),
|
||||
ExposedPorts: {
|
||||
[`${agentPort}/tcp`]: {},
|
||||
},
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
PortBindings: {
|
||||
[`${agentPort}/tcp`]: [{ HostPort: "0" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await container.start();
|
||||
return container.id;
|
||||
},
|
||||
|
||||
async destroy(sandboxId: string): Promise<void> {
|
||||
const container = dockerClient.getContainer(sandboxId);
|
||||
try {
|
||||
await container.stop({ t: 5 });
|
||||
} catch {}
|
||||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {}
|
||||
},
|
||||
|
||||
async getUrl(sandboxId: string): Promise<string> {
|
||||
const hostPort = await getPublishedDockerPort(sandboxId, agentPort);
|
||||
return `http://${publicHost}:${hostPort}`;
|
||||
},
|
||||
|
||||
async connectAgent(sandboxId: string, connectOptions: any): Promise<any> {
|
||||
const hostPort = await getPublishedDockerPort(sandboxId, agentPort);
|
||||
return await SandboxAgent.connect({
|
||||
baseUrl: `http://${backendHost}:${hostPort}`,
|
||||
...connectOptions,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeActorResult(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
if (typeof value === "function" || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const maybeToRecord = (value as { toRecord?: unknown }).toRecord;
|
||||
if (typeof maybeToRecord === "function") {
|
||||
return sanitizeActorResult(maybeToRecord.call(value), seen);
|
||||
}
|
||||
}
|
||||
|
||||
if (value === null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeActorResult(entry, seen)).filter((entry) => entry !== undefined);
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const sanitized = sanitizeActorResult(entry, seen);
|
||||
if (sanitized !== undefined) {
|
||||
next[key] = sanitized;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const baseTaskSandbox = sandboxActor({
|
||||
createProvider: async (c) => {
|
||||
const { config } = getActorRuntimeContext();
|
||||
const { workspaceId, taskId } = parseTaskSandboxKey(c.key);
|
||||
const workspace = await c.client().workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId,
|
||||
});
|
||||
const task = await workspace.getTask({ workspaceId, taskId });
|
||||
const providerId = resolveSandboxProviderId(config, task.providerId);
|
||||
|
||||
if (providerId === "e2b") {
|
||||
return e2b({
|
||||
create: () => ({
|
||||
template: config.providers.e2b.template ?? "sandbox-agent-full-0.3.x",
|
||||
envs: sandboxEnvObject(),
|
||||
}),
|
||||
installAgents: ["claude", "codex"],
|
||||
});
|
||||
}
|
||||
|
||||
return createLocalSandboxProvider(config.providers.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
|
||||
},
|
||||
});
|
||||
|
||||
async function broadcastProcesses(c: any, actions: Record<string, (...args: any[]) => Promise<any>>): Promise<void> {
|
||||
try {
|
||||
const listed = await actions.listProcesses(c);
|
||||
c.broadcast("processesUpdated", {
|
||||
type: "processesUpdated",
|
||||
processes: listed.processes ?? [],
|
||||
});
|
||||
} catch {
|
||||
// Process broadcasts are best-effort. Callers still receive the primary action result.
|
||||
}
|
||||
}
|
||||
|
||||
async function providerForConnection(c: any): Promise<any | null> {
|
||||
if (c.state.sandboxDestroyed || !c.state.sandboxId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (c.vars.provider) {
|
||||
return c.vars.provider;
|
||||
}
|
||||
|
||||
const providerFactory = baseTaskSandbox.config.actions as Record<string, unknown>;
|
||||
void providerFactory;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const { workspaceId, taskId } = parseTaskSandboxKey(c.key);
|
||||
const workspace = await c.client().workspace.getOrCreate(workspaceKey(workspaceId), {
|
||||
createWithInput: workspaceId,
|
||||
});
|
||||
const task = await workspace.getTask({ workspaceId, taskId });
|
||||
const providerId = resolveSandboxProviderId(config, task.providerId);
|
||||
|
||||
const provider =
|
||||
providerId === "e2b"
|
||||
? e2b({
|
||||
create: () => ({
|
||||
template: config.providers.e2b.template ?? "sandbox-agent-full-0.3.x",
|
||||
envs: sandboxEnvObject(),
|
||||
}),
|
||||
installAgents: ["claude", "codex"],
|
||||
})
|
||||
: createLocalSandboxProvider(config.providers.local.image ?? process.env.HF_LOCAL_SANDBOX_IMAGE ?? DEFAULT_LOCAL_SANDBOX_IMAGE);
|
||||
|
||||
c.vars.provider = provider;
|
||||
return provider;
|
||||
}
|
||||
|
||||
const baseActions = baseTaskSandbox.config.actions as Record<string, (c: any, ...args: any[]) => Promise<any>>;
|
||||
|
||||
export const taskSandbox = actor({
|
||||
...baseTaskSandbox.config,
|
||||
options: {
|
||||
...baseTaskSandbox.config.options,
|
||||
actionTimeout: 10 * 60_000,
|
||||
},
|
||||
actions: {
|
||||
...baseActions,
|
||||
async createSession(c: any, request: any): Promise<any> {
|
||||
const session = await baseActions.createSession(c, request);
|
||||
const sessionId = typeof request?.id === "string" && request.id.length > 0 ? request.id : session?.id;
|
||||
const modeId = modeIdForAgent(request?.agent);
|
||||
if (sessionId && modeId) {
|
||||
try {
|
||||
await baseActions.rawSendSessionMethod(c, sessionId, "session/set_mode", { modeId });
|
||||
} catch {
|
||||
// Session mode updates are best-effort.
|
||||
}
|
||||
}
|
||||
return sanitizeActorResult(session);
|
||||
},
|
||||
|
||||
async resumeSession(c: any, sessionId: string): Promise<any> {
|
||||
return sanitizeActorResult(await baseActions.resumeSession(c, sessionId));
|
||||
},
|
||||
|
||||
async resumeOrCreateSession(c: any, request: any): Promise<any> {
|
||||
return sanitizeActorResult(await baseActions.resumeOrCreateSession(c, request));
|
||||
},
|
||||
|
||||
async getSession(c: any, sessionId: string): Promise<any> {
|
||||
return sanitizeActorResult(await baseActions.getSession(c, sessionId));
|
||||
},
|
||||
|
||||
async listSessions(c: any, query?: any): Promise<any> {
|
||||
return sanitizeActorResult(await baseActions.listSessions(c, query));
|
||||
},
|
||||
|
||||
async destroySession(c: any, sessionId: string): Promise<any> {
|
||||
return sanitizeActorResult(await baseActions.destroySession(c, sessionId));
|
||||
},
|
||||
|
||||
async sendPrompt(c: any, request: { sessionId: string; prompt: string }): Promise<any> {
|
||||
const text = typeof request?.prompt === "string" ? request.prompt.trim() : "";
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await baseActions.resumeSession(c, request.sessionId);
|
||||
if (!session || typeof session.prompt !== "function") {
|
||||
throw new Error(`session '${request.sessionId}' not found`);
|
||||
}
|
||||
|
||||
return sanitizeActorResult(await session.prompt([{ type: "text", text }]));
|
||||
},
|
||||
|
||||
async createProcess(c: any, request: any): Promise<any> {
|
||||
const created = await baseActions.createProcess(c, request);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
return created;
|
||||
},
|
||||
|
||||
async runProcess(c: any, request: any): Promise<any> {
|
||||
const result = await baseActions.runProcess(c, request);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
return result;
|
||||
},
|
||||
|
||||
async stopProcess(c: any, processId: string, query?: any): Promise<any> {
|
||||
const stopped = await baseActions.stopProcess(c, processId, query);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
return stopped;
|
||||
},
|
||||
|
||||
async killProcess(c: any, processId: string, query?: any): Promise<any> {
|
||||
const killed = await baseActions.killProcess(c, processId, query);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
return killed;
|
||||
},
|
||||
|
||||
async deleteProcess(c: any, processId: string): Promise<void> {
|
||||
await baseActions.deleteProcess(c, processId);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
},
|
||||
|
||||
async sandboxAgentConnection(c: any): Promise<{ endpoint: string; token?: string }> {
|
||||
const provider = await providerForConnection(c);
|
||||
if (!provider || !c.state.sandboxId) {
|
||||
return { endpoint: "mock://terminal-unavailable" };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
endpoint: await provider.getUrl(c.state.sandboxId),
|
||||
};
|
||||
} catch {
|
||||
return { endpoint: "mock://terminal-unavailable" };
|
||||
}
|
||||
},
|
||||
|
||||
async providerState(c: any): Promise<{ providerId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
|
||||
const { config } = getActorRuntimeContext();
|
||||
const { taskId } = parseTaskSandboxKey(c.key);
|
||||
const at = Date.now();
|
||||
const providerId = resolveSandboxProviderId(config, c.state.providerName === "e2b" ? "e2b" : c.state.providerName === "docker" ? "local" : null);
|
||||
|
||||
if (c.state.sandboxDestroyed) {
|
||||
return { providerId, sandboxId: taskId, state: "destroyed", at };
|
||||
}
|
||||
|
||||
if (!c.state.sandboxId) {
|
||||
return { providerId, sandboxId: taskId, state: "pending", at };
|
||||
}
|
||||
|
||||
try {
|
||||
const health = await baseActions.getHealth(c);
|
||||
return {
|
||||
providerId,
|
||||
sandboxId: taskId,
|
||||
state: health.status === "ok" ? "running" : "degraded",
|
||||
at,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
providerId,
|
||||
sandboxId: taskId,
|
||||
state: "error",
|
||||
at,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async repoCwd(): Promise<{ cwd: string }> {
|
||||
return { cwd: SANDBOX_REPO_CWD };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { SANDBOX_REPO_CWD };
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type { ProviderId } from "@sandbox-agent/foundry-shared";
|
||||
import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
||||
|
||||
export interface TaskStatusSyncInput {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface SetIntervalCommand {
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface TaskStatusSyncState extends PollingControlState {
|
||||
workspaceId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const CONTROL = {
|
||||
start: "task.status_sync.control.start",
|
||||
stop: "task.status_sync.control.stop",
|
||||
setInterval: "task.status_sync.control.set_interval",
|
||||
force: "task.status_sync.control.force",
|
||||
} as const;
|
||||
|
||||
async function pollSessionStatus(c: { state: TaskStatusSyncState }): Promise<void> {
|
||||
const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId);
|
||||
const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId });
|
||||
|
||||
const parent = getTask(c, c.state.workspaceId, c.state.repoId, c.state.taskId);
|
||||
await parent.syncWorkbenchSessionStatus({
|
||||
sessionId: c.state.sessionId,
|
||||
status: status.status,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export const taskStatusSync = actor({
|
||||
queues: {
|
||||
[CONTROL.start]: queue(),
|
||||
[CONTROL.stop]: queue(),
|
||||
[CONTROL.setInterval]: queue(),
|
||||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Task Status Sync",
|
||||
icon: "signal",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
createState: (_c, input: TaskStatusSyncInput): TaskStatusSyncState => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
taskId: input.taskId,
|
||||
providerId: input.providerId,
|
||||
sandboxId: input.sandboxId,
|
||||
sessionId: input.sessionId,
|
||||
intervalMs: input.intervalMs,
|
||||
running: true,
|
||||
}),
|
||||
actions: {
|
||||
async start(c): Promise<void> {
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async stop(c): Promise<void> {
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async force(c): Promise<void> {
|
||||
const self = selfTaskStatusSync(c);
|
||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
||||
},
|
||||
},
|
||||
run: workflow(async (ctx) => {
|
||||
await runWorkflowPollingLoop<TaskStatusSyncState>(ctx, {
|
||||
loopName: "task-status-sync-loop",
|
||||
control: CONTROL,
|
||||
onPoll: async (loopCtx) => {
|
||||
try {
|
||||
await pollSessionStatus(loopCtx);
|
||||
} catch (error) {
|
||||
logActorWarning("task-status-sync", "poll failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
stack: resolveErrorStack(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
@ -41,7 +41,7 @@ export interface TaskInput {
|
|||
repoId: string;
|
||||
taskId: string;
|
||||
repoRemote: string;
|
||||
repoLocalPath: string;
|
||||
repoLocalPath?: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
|
|
@ -139,7 +139,7 @@ export const task = actor({
|
|||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 60_000,
|
||||
timeout: 5 * 60_000,
|
||||
});
|
||||
return expectQueueResponse<TaskRecord>(result);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
// @ts-nocheck
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename } from "node:path";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance, selfTask } from "../handles.js";
|
||||
import { getOrCreateProject, getOrCreateTaskSandbox, getOrCreateWorkspace, getTaskSandbox, selfTask } from "../handles.js";
|
||||
import { SANDBOX_REPO_CWD } from "../sandbox/index.js";
|
||||
import { resolveSandboxProviderId } from "../../sandbox-config.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes, taskWorkbenchSessions } from "./db/schema.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import { taskWorkflowQueueName } from "./workflow/queue.js";
|
||||
|
||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||
|
||||
function emptyGitState() {
|
||||
return {
|
||||
|
|
@ -57,18 +56,22 @@ async function ensureTaskRuntimeCacheColumns(c: any): Promise<void> {
|
|||
}
|
||||
|
||||
function defaultModelForAgent(agentType: string | null | undefined) {
|
||||
return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4";
|
||||
return agentType === "codex" ? "gpt-5.3-codex" : "claude-sonnet-4";
|
||||
}
|
||||
|
||||
function isCodexModel(model: string) {
|
||||
return model.startsWith("gpt-") || model.startsWith("o");
|
||||
}
|
||||
|
||||
function agentKindForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
if (isCodexModel(model)) {
|
||||
return "Codex";
|
||||
}
|
||||
return "Claude";
|
||||
}
|
||||
|
||||
export function agentTypeForModel(model: string) {
|
||||
if (model === "gpt-4o" || model === "o3") {
|
||||
if (isCodexModel(model)) {
|
||||
return "codex";
|
||||
}
|
||||
return "claude";
|
||||
|
|
@ -291,6 +294,121 @@ function shellFragment(parts: string[]): string {
|
|||
return parts.join(" && ");
|
||||
}
|
||||
|
||||
function stableSandboxId(c: any): string {
|
||||
return c.state.taskId;
|
||||
}
|
||||
|
||||
async function getTaskSandboxRuntime(
|
||||
c: any,
|
||||
record: any,
|
||||
): Promise<{
|
||||
sandbox: any;
|
||||
sandboxId: string;
|
||||
providerId: string;
|
||||
switchTarget: string;
|
||||
cwd: string;
|
||||
}> {
|
||||
const { config } = getActorRuntimeContext();
|
||||
const sandboxId = stableSandboxId(c);
|
||||
const providerId = resolveSandboxProviderId(config, record.providerId ?? c.state.providerId ?? null);
|
||||
const sandbox = await getOrCreateTaskSandbox(c, c.state.workspaceId, sandboxId, {});
|
||||
const actorId = typeof sandbox.resolve === "function" ? await sandbox.resolve().catch(() => null) : null;
|
||||
const switchTarget = providerId === "local" ? `sandbox://local/${sandboxId}` : `sandbox://e2b/${sandboxId}`;
|
||||
const now = Date.now();
|
||||
|
||||
await c.db
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId,
|
||||
providerId,
|
||||
sandboxActorId: typeof actorId === "string" ? actorId : null,
|
||||
switchTarget,
|
||||
cwd: SANDBOX_REPO_CWD,
|
||||
statusMessage: "sandbox ready",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId: typeof actorId === "string" ? actorId : null,
|
||||
switchTarget,
|
||||
cwd: SANDBOX_REPO_CWD,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await c.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSandboxId: sandboxId,
|
||||
activeSwitchTarget: switchTarget,
|
||||
activeCwd: SANDBOX_REPO_CWD,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
return {
|
||||
sandbox,
|
||||
sandboxId,
|
||||
providerId,
|
||||
switchTarget,
|
||||
cwd: SANDBOX_REPO_CWD,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureSandboxRepo(c: any, sandbox: any, record: any): Promise<void> {
|
||||
if (!record.branchName) {
|
||||
throw new Error("cannot prepare a sandbox repo before the task branch exists");
|
||||
}
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
let repoLocalPath = c.state.repoLocalPath;
|
||||
if (!repoLocalPath) {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
const ensured = await project.ensure({ remoteUrl: c.state.repoRemote });
|
||||
repoLocalPath = ensured.localPath;
|
||||
c.state.repoLocalPath = repoLocalPath;
|
||||
}
|
||||
|
||||
const baseRef = await driver.git.remoteDefaultBaseRef(repoLocalPath);
|
||||
const sandboxRepoRoot = dirname(SANDBOX_REPO_CWD);
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`mkdir -p ${JSON.stringify(sandboxRepoRoot)}`,
|
||||
"git config --global credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'",
|
||||
`if [ ! -d ${JSON.stringify(`${SANDBOX_REPO_CWD}/.git`)} ]; then rm -rf ${JSON.stringify(SANDBOX_REPO_CWD)} && git clone ${JSON.stringify(
|
||||
c.state.repoRemote,
|
||||
)} ${JSON.stringify(SANDBOX_REPO_CWD)}; fi`,
|
||||
`cd ${JSON.stringify(SANDBOX_REPO_CWD)}`,
|
||||
"git fetch origin --prune",
|
||||
`if git show-ref --verify --quiet refs/remotes/origin/${JSON.stringify(record.branchName).slice(1, -1)}; then target_ref=${JSON.stringify(
|
||||
`origin/${record.branchName}`,
|
||||
)}; else target_ref=${JSON.stringify(baseRef)}; fi`,
|
||||
`git checkout -B ${JSON.stringify(record.branchName)} \"$target_ref\"`,
|
||||
];
|
||||
const result = await sandbox.runProcess({
|
||||
command: "bash",
|
||||
args: ["-lc", script.join("; ")],
|
||||
cwd: "/",
|
||||
env: auth?.githubToken
|
||||
? {
|
||||
GH_TOKEN: auth.githubToken,
|
||||
GITHUB_TOKEN: auth.githubToken,
|
||||
}
|
||||
: undefined,
|
||||
timeoutMs: 5 * 60_000,
|
||||
});
|
||||
|
||||
if ((result.exitCode ?? 0) !== 0) {
|
||||
throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeInSandbox(
|
||||
c: any,
|
||||
params: {
|
||||
|
|
@ -300,14 +418,20 @@ async function executeInSandbox(
|
|||
label: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; result: string }> {
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(c.state.providerId);
|
||||
return await provider.executeCommand({
|
||||
workspaceId: c.state.workspaceId,
|
||||
sandboxId: params.sandboxId,
|
||||
command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`,
|
||||
label: params.label,
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const runtime = await getTaskSandboxRuntime(c, record);
|
||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||
const response = await runtime.sandbox.runProcess({
|
||||
command: "bash",
|
||||
args: ["-lc", shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command])],
|
||||
cwd: "/",
|
||||
timeoutMs: 5 * 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
exitCode: response.exitCode ?? 0,
|
||||
result: [response.stdout, response.stderr].filter(Boolean).join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> {
|
||||
|
|
@ -501,13 +625,13 @@ async function writeCachedGitState(c: any, gitState: { fileChanges: Array<any>;
|
|||
}
|
||||
|
||||
async function readSessionTranscript(c: any, record: any, sessionId: string) {
|
||||
const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null;
|
||||
const sandboxId = record.activeSandboxId ?? stableSandboxId(c);
|
||||
if (!sandboxId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId);
|
||||
const page = await sandbox.listSessionEvents({
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, sandboxId);
|
||||
const page = await sandbox.getEvents({
|
||||
sessionId,
|
||||
limit: 100,
|
||||
});
|
||||
|
|
@ -599,7 +723,13 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
|||
function buildSessionSummary(record: any, meta: any): any {
|
||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||
const sessionStatus =
|
||||
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
|
||||
meta.status === "pending_provision" || meta.status === "pending_session_create"
|
||||
? meta.status
|
||||
: meta.status === "ready" && derivedSandboxSessionId
|
||||
? activeSessionStatus(record, derivedSandboxSessionId)
|
||||
: meta.status === "error"
|
||||
? "error"
|
||||
: "ready";
|
||||
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||
let unread = Boolean(meta.unread);
|
||||
if (thinkingSinceMs && sessionStatus !== "running") {
|
||||
|
|
@ -617,6 +747,7 @@ function buildSessionSummary(record: any, meta: any): any {
|
|||
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
||||
unread,
|
||||
created: Boolean(meta.created || derivedSandboxSessionId),
|
||||
errorMessage: meta.errorMessage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -633,6 +764,7 @@ function buildSessionDetailFromMeta(record: any, meta: any): any {
|
|||
thinkingSinceMs: summary.thinkingSinceMs,
|
||||
unread: summary.unread,
|
||||
created: summary.created,
|
||||
errorMessage: summary.errorMessage,
|
||||
draft: {
|
||||
text: meta.draftText ?? "",
|
||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||
|
|
@ -655,7 +787,7 @@ export async function buildTaskSummary(c: any): Promise<any> {
|
|||
id: c.state.taskId,
|
||||
repoId: c.state.repoId,
|
||||
title: record.title ?? "New Task",
|
||||
status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new",
|
||||
status: record.status ?? "new",
|
||||
repoName: repoLabelFromRemote(c.state.repoRemote),
|
||||
updatedAtMs: record.updatedAt,
|
||||
branch: record.branchName,
|
||||
|
|
@ -708,6 +840,24 @@ export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
|
|||
throw new Error(`Unknown workbench session tab: ${tabId}`);
|
||||
}
|
||||
|
||||
if (!meta.sandboxSessionId) {
|
||||
return buildSessionDetailFromMeta(record, meta);
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
|
||||
if (JSON.stringify(meta.transcript ?? []) !== JSON.stringify(transcript)) {
|
||||
await writeSessionTranscript(c, meta.tabId, transcript);
|
||||
return buildSessionDetailFromMeta(record, {
|
||||
...meta,
|
||||
transcript,
|
||||
transcriptUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Session detail reads should degrade to cached transcript data if the live sandbox is unavailable.
|
||||
}
|
||||
|
||||
return buildSessionDetailFromMeta(record, meta);
|
||||
}
|
||||
|
||||
|
|
@ -836,50 +986,16 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
|||
}
|
||||
|
||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||
let record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
// Fire-and-forget: enqueue provisioning without waiting to avoid self-deadlock
|
||||
// (this handler already runs inside the task workflow loop, so wait:true would deadlock).
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false });
|
||||
throw new Error("sandbox is provisioning — retry shortly");
|
||||
}
|
||||
|
||||
if (record.activeSessionId) {
|
||||
const existingSessions = await listSessionMetaRows(c);
|
||||
if (existingSessions.length === 0) {
|
||||
await ensureSessionMeta(c, {
|
||||
tabId: record.activeSessionId,
|
||||
sandboxSessionId: record.activeSessionId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sessionName: "Session 1",
|
||||
status: "ready",
|
||||
});
|
||||
await broadcastTaskUpdate(c, { sessionId: record.activeSessionId });
|
||||
return { tabId: record.activeSessionId };
|
||||
}
|
||||
}
|
||||
|
||||
const tabId = `tab-${randomUUID()}`;
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
await ensureSessionMeta(c, {
|
||||
tabId,
|
||||
model: model ?? defaultModelForAgent(record.agentType),
|
||||
sandboxSessionId: tabId,
|
||||
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
|
||||
created: false,
|
||||
});
|
||||
|
||||
const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId();
|
||||
const self = selfTask(c);
|
||||
if (!record.activeSandboxId && !String(record.status ?? "").startsWith("init_")) {
|
||||
await self.send("task.command.provision", { providerId }, { wait: false });
|
||||
}
|
||||
await self.send(
|
||||
"task.command.workbench.ensure_session",
|
||||
{ tabId, ...(model ? { model } : {}) },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
await ensureWorkbenchSession(c, tabId, model);
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return { tabId };
|
||||
}
|
||||
|
|
@ -891,39 +1007,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
}
|
||||
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "pending_provision",
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meta.sandboxSessionId && record.activeSessionId && meta.status === "pending_provision") {
|
||||
const existingTabForActiveSession = await readSessionMetaBySandboxSessionId(c, record.activeSessionId);
|
||||
if (existingTabForActiveSession && existingTabForActiveSession.tabId !== tabId) {
|
||||
await updateSessionMeta(c, existingTabForActiveSession.tabId, {
|
||||
closed: 1,
|
||||
});
|
||||
}
|
||||
await updateSessionMeta(c, tabId, {
|
||||
sandboxSessionId: record.activeSessionId,
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
created: 1,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.sandboxSessionId) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
});
|
||||
if (meta.sandboxSessionId && meta.status === "ready") {
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
|
|
@ -931,40 +1015,31 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
|
|||
return;
|
||||
}
|
||||
|
||||
const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null;
|
||||
const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
status: "error",
|
||||
errorMessage: "cannot create session without a sandbox cwd",
|
||||
});
|
||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
await updateSessionMeta(c, tabId, {
|
||||
sandboxSessionId: meta.sandboxSessionId ?? tabId,
|
||||
status: "pending_session_create",
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const created = await sandbox.createSession({
|
||||
prompt: "",
|
||||
cwd,
|
||||
const runtime = await getTaskSandboxRuntime(c, record);
|
||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||
await runtime.sandbox.createSession({
|
||||
id: meta.sandboxSessionId ?? tabId,
|
||||
agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)),
|
||||
model: model ?? meta.model ?? defaultModelForAgent(record.agentType),
|
||||
sessionInit: {
|
||||
cwd: runtime.cwd,
|
||||
},
|
||||
});
|
||||
if (!created.id) {
|
||||
throw new Error(created.error ?? "sandbox-agent session creation failed");
|
||||
}
|
||||
|
||||
await updateSessionMeta(c, tabId, {
|
||||
sandboxSessionId: created.id,
|
||||
sandboxSessionId: meta.sandboxSessionId ?? tabId,
|
||||
status: "ready",
|
||||
errorMessage: null,
|
||||
});
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: created.id,
|
||||
sessionId: meta.sandboxSessionId ?? tabId,
|
||||
});
|
||||
} catch (error) {
|
||||
await updateSessionMeta(c, tabId, {
|
||||
|
|
@ -1031,26 +1106,17 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str
|
|||
}
|
||||
|
||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
throw new Error("cannot send message without an active sandbox");
|
||||
}
|
||||
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
if (!prompt) {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
const runtime = await getTaskSandboxRuntime(c, record);
|
||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||
const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter(
|
||||
Boolean,
|
||||
);
|
||||
if (prompt.length === 0) {
|
||||
throw new Error("message text is required");
|
||||
}
|
||||
|
||||
await sandbox.sendPrompt({
|
||||
sessionId: meta.sandboxSessionId,
|
||||
prompt,
|
||||
notification: true,
|
||||
});
|
||||
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
unread: 0,
|
||||
created: 1,
|
||||
|
|
@ -1069,32 +1135,28 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
|
|||
.where(eq(taskRuntime.id, 1))
|
||||
.run();
|
||||
|
||||
const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, meta.sandboxSessionId, {
|
||||
workspaceId: c.state.workspaceId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: c.state.taskId,
|
||||
providerId: c.state.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: meta.sandboxSessionId,
|
||||
intervalMs: STATUS_SYNC_INTERVAL_MS,
|
||||
});
|
||||
await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS });
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
await broadcastTaskUpdate(c, { sessionId });
|
||||
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "running", Date.now());
|
||||
|
||||
try {
|
||||
await runtime.sandbox.sendPrompt({
|
||||
sessionId: meta.sandboxSessionId,
|
||||
prompt: prompt.join("\n\n"),
|
||||
});
|
||||
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "idle", Date.now());
|
||||
} catch (error) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
status: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "error", Date.now());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
|
||||
const record = await ensureWorkbenchSeeded(c);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
}
|
||||
const meta = await requireReadySessionMeta(c, sessionId);
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.cancelSession({ sessionId: meta.sandboxSessionId });
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
await sandbox.destroySession(meta.sandboxSessionId);
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
thinkingSinceMs: null,
|
||||
});
|
||||
|
|
@ -1178,9 +1240,9 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
|
|||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
if (record.activeSandboxId && meta.sandboxSessionId) {
|
||||
const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId);
|
||||
await sandbox.destroySession({ sessionId: meta.sandboxSessionId });
|
||||
if (meta.sandboxSessionId) {
|
||||
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||
await sandbox.destroySession(meta.sandboxSessionId);
|
||||
}
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
closed: 1,
|
||||
|
|
@ -1216,9 +1278,16 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
|||
if (!record.branchName) {
|
||||
throw new Error("cannot publish PR without a branch");
|
||||
}
|
||||
let repoLocalPath = c.state.repoLocalPath;
|
||||
if (!repoLocalPath) {
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote);
|
||||
const result = await project.ensure({ remoteUrl: c.state.repoRemote });
|
||||
repoLocalPath = result.localPath;
|
||||
c.state.repoLocalPath = repoLocalPath;
|
||||
}
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||
const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, {
|
||||
const created = await driver.github.createPr(repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, {
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
});
|
||||
await c.db
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateTaskStatusSync } from "../../handles.js";
|
||||
import { getTaskSandbox } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
|
||||
|
|
@ -25,21 +24,27 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||
|
||||
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.activeSandboxId ? (record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null) : null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const target = await provider.attachTarget({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId ?? "",
|
||||
});
|
||||
let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? "";
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
try {
|
||||
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId);
|
||||
const connection = await sandbox.sandboxAgentConnection();
|
||||
if (typeof connection?.endpoint === "string" && connection.endpoint.length > 0) {
|
||||
target = connection.endpoint;
|
||||
}
|
||||
} catch {
|
||||
// Best effort; keep the last known switch target if the sandbox actor is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
await appendHistory(loopCtx, "task.attach", {
|
||||
target: target.target,
|
||||
target,
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
|
||||
await msg.complete({
|
||||
target: target.target,
|
||||
target,
|
||||
sessionId: record.activeSessionId,
|
||||
});
|
||||
}
|
||||
|
|
@ -71,63 +76,14 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
|
||||
if (record.activeSandboxId && record.activeSessionId) {
|
||||
try {
|
||||
const sync = await getOrCreateTaskStatusSync(
|
||||
loopCtx,
|
||||
loopCtx.state.workspaceId,
|
||||
loopCtx.state.repoId,
|
||||
loopCtx.state.taskId,
|
||||
record.activeSandboxId,
|
||||
record.activeSessionId,
|
||||
{
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId: record.providerId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
intervalMs: 2_000,
|
||||
},
|
||||
);
|
||||
await withTimeout(sync.stop(), 15_000, "task status sync stop");
|
||||
} catch (error) {
|
||||
logActorWarning("task.commands", "failed to stop status sync during archive", {
|
||||
if (record.activeSandboxId) {
|
||||
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => {
|
||||
logActorWarning("task.commands", "failed to release sandbox during archive", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
sessionId: record.activeSessionId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
const workspaceId = loopCtx.state.workspaceId;
|
||||
const repoId = loopCtx.state.repoId;
|
||||
const taskId = loopCtx.state.taskId;
|
||||
const sandboxId = record.activeSandboxId;
|
||||
|
||||
// Do not block archive finalization on provider stop. Some provider stop calls can
|
||||
// run longer than the synchronous archive UX budget.
|
||||
void withTimeout(
|
||||
provider.releaseSandbox({
|
||||
workspaceId,
|
||||
sandboxId,
|
||||
}),
|
||||
45_000,
|
||||
"provider releaseSandbox",
|
||||
).catch((error) => {
|
||||
logActorWarning("task.commands", "failed to release sandbox during archive", {
|
||||
workspaceId,
|
||||
repoId,
|
||||
taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
});
|
||||
|
|
@ -150,13 +106,7 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const activeSandbox = record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null;
|
||||
const provider = providers.get(activeSandbox?.providerId ?? record.providerId);
|
||||
await provider.destroySandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: record.activeSandboxId,
|
||||
});
|
||||
await getTaskSandbox(loopCtx, loopCtx.state.workspaceId, record.activeSandboxId).destroy();
|
||||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { getCurrentRecord } from "./common.js";
|
||||
import {
|
||||
initAssertNameActivity,
|
||||
initBootstrapDbActivity,
|
||||
initCompleteActivity,
|
||||
initCreateSandboxActivity,
|
||||
initCreateSessionActivity,
|
||||
initEnqueueProvisionActivity,
|
||||
initEnsureAgentActivity,
|
||||
initEnsureNameActivity,
|
||||
initExposeSandboxActivity,
|
||||
initFailedActivity,
|
||||
initStartSandboxInstanceActivity,
|
||||
initStartStatusSyncActivity,
|
||||
initWriteDbActivity,
|
||||
} from "./init.js";
|
||||
import {
|
||||
handleArchiveActivity,
|
||||
|
|
@ -27,7 +19,6 @@ import {
|
|||
killDestroySandboxActivity,
|
||||
killWriteDbActivity,
|
||||
} from "./commands.js";
|
||||
import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js";
|
||||
import { TASK_QUEUE_NAMES } from "./queue.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
|
|
@ -63,7 +54,6 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await loopCtx.step("init-enqueue-provision", async () => initEnqueueProvisionActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
|
||||
|
||||
try {
|
||||
await msg.complete(currentRecord);
|
||||
} catch (error) {
|
||||
|
|
@ -74,40 +64,26 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
},
|
||||
|
||||
"task.command.provision": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
await loopCtx.removed("init-failed", "step");
|
||||
await loopCtx.removed("init-failed-v2", "step");
|
||||
try {
|
||||
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
|
||||
await loopCtx.step({
|
||||
name: "init-ensure-name",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => initEnsureNameActivity(loopCtx),
|
||||
});
|
||||
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
|
||||
|
||||
const sandbox = await loopCtx.step({
|
||||
name: "init-create-sandbox",
|
||||
timeout: 180_000,
|
||||
run: async () => initCreateSandboxActivity(loopCtx, body),
|
||||
});
|
||||
const agent = await loopCtx.step({
|
||||
name: "init-ensure-agent",
|
||||
timeout: 180_000,
|
||||
run: async () => initEnsureAgentActivity(loopCtx, body, sandbox),
|
||||
});
|
||||
const sandboxInstanceReady = await loopCtx.step({
|
||||
name: "init-start-sandbox-instance",
|
||||
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,
|
||||
run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady),
|
||||
});
|
||||
|
||||
await loopCtx.step("init-write-db", async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady));
|
||||
await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session));
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
||||
await loopCtx.removed("init-create-sandbox", "step");
|
||||
await loopCtx.removed("init-ensure-agent", "step");
|
||||
await loopCtx.removed("init-start-sandbox-instance", "step");
|
||||
await loopCtx.removed("init-expose-sandbox", "step");
|
||||
await loopCtx.removed("init-create-session", "step");
|
||||
await loopCtx.removed("init-write-db", "step");
|
||||
await loopCtx.removed("init-start-status-sync", "step");
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, msg.body));
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error));
|
||||
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error));
|
||||
await msg.complete({
|
||||
ok: false,
|
||||
error: resolveErrorMessage(error),
|
||||
|
|
@ -171,7 +147,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
try {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 30_000,
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await msg.complete(created);
|
||||
|
|
@ -276,18 +252,6 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
|||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.status_sync.result": async (loopCtx, msg) => {
|
||||
const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body));
|
||||
|
||||
if (transitionedToIdle) {
|
||||
const { config } = getActorRuntimeContext();
|
||||
if (config.auto_submit) {
|
||||
await loopCtx.step("idle-submit-pr", async () => idleSubmitPrActivity(loopCtx));
|
||||
}
|
||||
await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export async function runTaskWorkflow(ctx: any): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,14 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js";
|
||||
import { getOrCreateHistory, getOrCreateProject, selfTask } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
import { taskWorkflowQueueName } from "./queue.js";
|
||||
import { enqueuePendingWorkbenchSessions } from "../workbench.js";
|
||||
|
||||
const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000;
|
||||
|
||||
function getInitCreateSandboxActivityTimeoutMs(): number {
|
||||
const raw = process.env.HF_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function debugInit(loopCtx: any, message: string, context?: Record<string, unknown>): void {
|
||||
loopCtx.log.debug({
|
||||
msg: message,
|
||||
scope: "task.init",
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
...(context ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
|
||||
|
|
@ -42,94 +17,70 @@ async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
|
|||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
|
||||
}
|
||||
|
||||
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
run(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId ?? defaultSandboxProviderId(config);
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
|
||||
|
||||
try {
|
||||
await ensureTaskRuntimeCacheColumns(db);
|
||||
await ensureTaskRuntimeCacheColumns(loopCtx.db);
|
||||
|
||||
await db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
await loopCtx.db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
providerId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
await loopCtx.db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
gitStateJson: null,
|
||||
gitStateUpdatedAt: null,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
gitStateJson: null,
|
||||
gitStateUpdatedAt: null,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: initialStatusMessage,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
const detail = resolveErrorMessage(error);
|
||||
throw new Error(`task init bootstrap db failed: ${detail}`);
|
||||
}
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
|
|
@ -143,12 +94,13 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro
|
|||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
const self = selfTask(loopCtx);
|
||||
try {
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
|
|
@ -178,8 +130,16 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
let repoLocalPath = loopCtx.state.repoLocalPath;
|
||||
if (!repoLocalPath) {
|
||||
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
|
||||
const result = await project.ensure({ remoteUrl: loopCtx.state.repoRemote });
|
||||
repoLocalPath = result.localPath;
|
||||
loopCtx.state.repoLocalPath = repoLocalPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||
await driver.git.fetch(repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "fetch before naming failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
|
|
@ -188,13 +148,12 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
|||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null })).map(
|
||||
|
||||
const remoteBranches = (await driver.git.listRemoteBranches(repoLocalPath, { githubToken: auth?.githubToken ?? null })).map(
|
||||
(branch: any) => branch.branchName,
|
||||
);
|
||||
|
||||
const project = await getOrCreateProject(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.repoRemote);
|
||||
const reservedBranches = await project.listReservedBranches({});
|
||||
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: loopCtx.state.task,
|
||||
explicitTitle: loopCtx.state.explicitTitle ?? undefined,
|
||||
|
|
@ -248,388 +207,42 @@ export async function initAssertNameActivity(loopCtx: any): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "sandbox_allocated",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
const timeoutMs = getInitCreateSandboxActivityTimeoutMs();
|
||||
const startedAt = Date.now();
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox started", {
|
||||
providerId,
|
||||
timeoutMs,
|
||||
supportsSessionReuse: provider.capabilities().supportsSessionReuse,
|
||||
});
|
||||
|
||||
if (provider.capabilities().supportsSessionReuse) {
|
||||
const runtime = await loopCtx.db.select({ activeSandboxId: taskRuntime.activeSandboxId }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get();
|
||||
|
||||
const existing = await loopCtx.db
|
||||
.select({ sandboxId: taskSandboxes.sandboxId })
|
||||
.from(taskSandboxes)
|
||||
.where(eq(taskSandboxes.providerId, providerId))
|
||||
.orderBy(desc(taskSandboxes.updatedAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const sandboxId = runtime?.activeSandboxId ?? existing?.sandboxId ?? null;
|
||||
if (sandboxId) {
|
||||
debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId });
|
||||
try {
|
||||
const resumed = await withActivityTimeout(timeoutMs, "resumeSandbox", async () =>
|
||||
provider.resumeSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId,
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox resume succeeded", {
|
||||
sandboxId: resumed.sandboxId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return resumed;
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "resume sandbox failed; creating a new sandbox", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", {
|
||||
branchName: loopCtx.state.branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
const sandbox = await withActivityTimeout(timeoutMs, "createSandbox", async () =>
|
||||
provider.createSandbox({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
repoRemote: loopCtx.state.repoRemote,
|
||||
branchName: loopCtx.state.branchName,
|
||||
taskId: loopCtx.state.taskId,
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
debug: (message, context) => debugInit(loopCtx, message, context),
|
||||
}),
|
||||
);
|
||||
|
||||
debugInit(loopCtx, "init_create_sandbox create succeeded", {
|
||||
sandboxId: sandbox.sandboxId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return sandbox;
|
||||
} catch (error) {
|
||||
debugInit(loopCtx, "init_create_sandbox failed", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "agent_installing",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const provider = providers.get(providerId);
|
||||
return await provider.ensureSandboxAgent({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "agent_starting",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
try {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
});
|
||||
|
||||
await sandboxInstance.ensure({
|
||||
metadata: sandbox.metadata,
|
||||
status: "ready",
|
||||
agentEndpoint: agent.endpoint,
|
||||
agentToken: agent.token,
|
||||
});
|
||||
|
||||
const actorId = typeof (sandboxInstance as any).resolve === "function" ? await (sandboxInstance as any).resolve() : null;
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
actorId: typeof actorId === "string" ? actorId : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `sandbox-instance ensure failed: ${detail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise<any> {
|
||||
await setTaskState(loopCtx, "init_create_session", "creating agent session");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "session_creating",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
if (!sandboxInstanceReady.ok) {
|
||||
return {
|
||||
id: null,
|
||||
status: "error",
|
||||
error: sandboxInstanceReady.error ?? "sandbox instance is not ready",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId);
|
||||
|
||||
const cwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : undefined;
|
||||
|
||||
return await sandboxInstance.createSession({
|
||||
prompt: typeof loopCtx.state.initialPrompt === "string" ? loopCtx.state.initialPrompt : buildAgentPrompt(loopCtx.state.task),
|
||||
cwd,
|
||||
agent: (loopCtx.state.agentType ?? config.default_agent) as any,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initExposeSandboxActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady?: { actorId?: string | null }): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
export async function initCompleteActivity(loopCtx: any, body: any): Promise<void> {
|
||||
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;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId ?? defaultSandboxProviderId(config);
|
||||
|
||||
await db
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
await setTaskState(loopCtx, "init_complete", "task initialized");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage: "sandbox ready",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initWriteDbActivity(
|
||||
loopCtx: any,
|
||||
body: any,
|
||||
sandbox: any,
|
||||
session: any,
|
||||
sandboxInstanceReady?: { actorId?: string | null },
|
||||
): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_write_db", "persisting task runtime");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const { config } = getActorRuntimeContext();
|
||||
const now = Date.now();
|
||||
const db = loopCtx.db;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
const activeSessionId = sessionHealthy ? sessionId : null;
|
||||
const statusMessage = sessionHealthy ? "session created" : session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
|
||||
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
|
||||
.update(taskTable)
|
||||
.set({
|
||||
providerId,
|
||||
status: sessionHealthy ? "running" : "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskSandboxes)
|
||||
.values({
|
||||
sandboxId: sandbox.sandboxId,
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskSandboxes.sandboxId,
|
||||
set: {
|
||||
providerId,
|
||||
sandboxActorId,
|
||||
switchTarget: sandbox.switchTarget,
|
||||
cwd: activeCwd,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
provisionStage: sessionHealthy ? "ready" : "error",
|
||||
statusMessage: "ready",
|
||||
provisionStage: "ready",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: sandbox.sandboxId,
|
||||
activeSessionId,
|
||||
activeSwitchTarget: sandbox.switchTarget,
|
||||
activeCwd,
|
||||
statusMessage,
|
||||
provisionStage: sessionHealthy ? "ready" : "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function initStartStatusSyncActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const sessionId = session?.id ?? null;
|
||||
if (!sessionId || session?.status === "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
await setTaskState(loopCtx, "init_start_status_sync", "starting session status sync");
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sync = await getOrCreateTaskStatusSync(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.taskId, sandbox.sandboxId, sessionId, {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "task.initialized",
|
||||
taskId: loopCtx.state.taskId,
|
||||
providerId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
sessionId,
|
||||
intervalMs: 2_000,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId },
|
||||
});
|
||||
|
||||
await sync.start();
|
||||
await sync.force();
|
||||
}
|
||||
|
||||
export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise<void> {
|
||||
const providerId = body?.providerId ?? loopCtx.state.providerId;
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionHealthy = Boolean(sessionId) && session?.status !== "error";
|
||||
if (sessionHealthy) {
|
||||
await setTaskState(loopCtx, "init_complete", "task initialized");
|
||||
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "task.initialized",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
payload: { providerId, sandboxId: sandbox.sandboxId, sessionId },
|
||||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
await enqueuePendingWorkbenchSessions(loopCtx);
|
||||
const self = selfTask(loopCtx);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_derived"), {}, { wait: false });
|
||||
if (sessionId) {
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.refresh_session_transcript"), { sessionId }, { wait: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable";
|
||||
await setTaskState(loopCtx, "error", detail);
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages: [detail],
|
||||
});
|
||||
loopCtx.state.initialized = false;
|
||||
loopCtx.state.initialized = true;
|
||||
}
|
||||
|
||||
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
|
||||
const now = Date.now();
|
||||
const detail = resolveErrorDetail(error);
|
||||
const messages = collectErrorMessages(error);
|
||||
const db = loopCtx.db;
|
||||
const { config, providers } = getActorRuntimeContext();
|
||||
const providerId = loopCtx.state.providerId ?? providers.defaultProviderId();
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = loopCtx.state.providerId ?? defaultSandboxProviderId(config);
|
||||
|
||||
await db
|
||||
await loopCtx.db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
|
|
@ -656,7 +269,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
await loopCtx.db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getTaskSandbox } from "../../handles.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
|
||||
import { taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
|
||||
|
||||
|
|
@ -22,15 +23,11 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
|
|||
}
|
||||
|
||||
const activeSandbox = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null;
|
||||
const providerId = activeSandbox?.providerId ?? record.providerId;
|
||||
const cwd = activeSandbox?.cwd ?? null;
|
||||
if (!cwd) {
|
||||
throw new Error("cannot push: active sandbox cwd is not set");
|
||||
}
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const provider = providers.get(providerId);
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
|
|
@ -52,15 +49,23 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
|
|||
`git push -u origin ${JSON.stringify(branchName)}`,
|
||||
].join("; ");
|
||||
|
||||
const result = await provider.executeCommand({
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
sandboxId: activeSandboxId,
|
||||
command: ["bash", "-lc", JSON.stringify(script)].join(" "),
|
||||
label: `git push ${branchName}`,
|
||||
const sandbox = getTaskSandbox(loopCtx, loopCtx.state.workspaceId, activeSandboxId);
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
const result = await sandbox.runProcess({
|
||||
command: "bash",
|
||||
args: ["-lc", script],
|
||||
cwd: "/",
|
||||
env: auth?.githubToken
|
||||
? {
|
||||
GH_TOKEN: auth.githubToken,
|
||||
GITHUB_TOKEN: auth.githubToken,
|
||||
}
|
||||
: undefined,
|
||||
timeoutMs: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`git push failed (${result.exitCode}): ${result.result}`);
|
||||
if ((result.exitCode ?? 0) !== 0) {
|
||||
throw new Error(`git push failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export const TASK_QUEUE_NAMES = [
|
|||
"task.command.workbench.close_session",
|
||||
"task.command.workbench.publish_pr",
|
||||
"task.command.workbench.revert_file",
|
||||
"task.status_sync.result",
|
||||
] as const;
|
||||
|
||||
export function taskWorkflowQueueName(name: string): string {
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../../services/github-auth.js";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
function mapSessionStatus(status: "running" | "idle" | "error") {
|
||||
if (status === "idle") return "idle";
|
||||
if (status === "error") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
export async function statusUpdateActivity(loopCtx: any, body: any): Promise<boolean> {
|
||||
const newStatus = mapSessionStatus(body.status);
|
||||
const wasIdle = loopCtx.state.previousStatus === "idle";
|
||||
const didTransition = newStatus === "idle" && !wasIdle;
|
||||
const isDuplicateStatus = loopCtx.state.previousStatus === newStatus;
|
||||
|
||||
if (isDuplicateStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
const runtime = await db
|
||||
.select({
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId,
|
||||
})
|
||||
.from(taskRuntime)
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.get();
|
||||
|
||||
const isActive = runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId;
|
||||
|
||||
if (isActive) {
|
||||
await db.update(taskTable).set({ status: newStatus, updatedAt: body.at }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `session:${body.status}`, updatedAt: body.at })
|
||||
.where(eq(taskSandboxes.sandboxId, body.sandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.status", {
|
||||
status: body.status,
|
||||
sessionId: body.sessionId,
|
||||
sandboxId: body.sandboxId,
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
loopCtx.state.previousStatus = newStatus;
|
||||
|
||||
const { driver } = getActorRuntimeContext();
|
||||
if (loopCtx.state.branchName) {
|
||||
driver.tmux.setWindowStatus(loopCtx.state.branchName, newStatus);
|
||||
}
|
||||
return didTransition;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function idleSubmitPrActivity(loopCtx: any): Promise<void> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const db = loopCtx.db;
|
||||
|
||||
const self = await db.select({ prSubmitted: taskTable.prSubmitted }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
|
||||
|
||||
if (self && self.prSubmitted) return;
|
||||
|
||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
||||
|
||||
try {
|
||||
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||
} catch (error) {
|
||||
logActorWarning("task.status-sync", "fetch before PR submit failed", {
|
||||
workspaceId: loopCtx.state.workspaceId,
|
||||
repoId: loopCtx.state.repoId,
|
||||
taskId: loopCtx.state.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!loopCtx.state.branchName || !loopCtx.state.title) {
|
||||
throw new Error("cannot submit PR before task has a branch and title");
|
||||
}
|
||||
|
||||
try {
|
||||
await pushActiveBranchActivity(loopCtx, {
|
||||
reason: "auto_submit_idle",
|
||||
historyKind: "task.push.auto",
|
||||
});
|
||||
|
||||
const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title, undefined, {
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
});
|
||||
|
||||
await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.step", {
|
||||
step: "pr_submit",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number,
|
||||
});
|
||||
|
||||
await appendHistory(loopCtx, "task.pr_created", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
prUrl: pr.url,
|
||||
prNumber: pr.number,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = resolveErrorDetail(error);
|
||||
await db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: `pr submit failed: ${detail}`,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.pr_create_failed", {
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
error: detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function idleNotifyActivity(loopCtx: any): Promise<void> {
|
||||
const { notifications } = getActorRuntimeContext();
|
||||
if (notifications && loopCtx.state.branchName) {
|
||||
await notifications.agentIdle(loopCtx.state.branchName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
// @ts-nocheck
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { Loop } from "rivetkit/workflow";
|
||||
import type {
|
||||
|
|
@ -37,6 +38,7 @@ import type {
|
|||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { availableSandboxProviderIds, defaultSandboxProviderId } from "../../sandbox-config.js";
|
||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||
|
|
@ -258,6 +260,24 @@ async function requireWorkbenchTask(c: any, taskId: string) {
|
|||
return getTask(c, c.state.workspaceId, repoId, taskId);
|
||||
}
|
||||
|
||||
async function waitForWorkbenchTaskReady(task: any, timeoutMs = 5 * 60_000): Promise<any> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
for (;;) {
|
||||
const record = await task.get();
|
||||
if (record?.branchName && record?.title) {
|
||||
return record;
|
||||
}
|
||||
if (record?.status === "error") {
|
||||
throw new Error("task initialization failed before the workbench session was ready");
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error("timed out waiting for task initialization");
|
||||
}
|
||||
await delay(1_000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
||||
* only. Task actors push summary updates into `task_summaries`, so clients do
|
||||
|
|
@ -343,8 +363,8 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
|
|||
async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskRecord> {
|
||||
assertWorkspace(c, input.workspaceId);
|
||||
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerId = input.providerId ?? providers.defaultProviderId();
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerId = input.providerId ?? defaultSandboxProviderId(config);
|
||||
|
||||
const repoId = input.repoId;
|
||||
const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get();
|
||||
|
|
@ -370,7 +390,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
|
|||
.run();
|
||||
|
||||
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
|
||||
await project.ensure({ remoteUrl });
|
||||
|
||||
const created = await project.createTask({
|
||||
task: input.task,
|
||||
|
|
@ -412,8 +431,8 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
|
|||
|
||||
async function refreshProviderProfilesMutation(c: any, command?: RefreshProviderProfilesCommand): Promise<void> {
|
||||
const body = command ?? {};
|
||||
const { providers } = getActorRuntimeContext();
|
||||
const providerIds: ProviderId[] = body.providerId ? [body.providerId] : providers.availableProviderIds();
|
||||
const { config } = getActorRuntimeContext();
|
||||
const providerIds: ProviderId[] = body.providerId ? [body.providerId] : availableSandboxProviderIds(config);
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
await c.db
|
||||
|
|
@ -457,7 +476,7 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
if (msg.name === "workspace.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "workspace-create-task",
|
||||
timeout: 12 * 60_000,
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||
});
|
||||
await msg.complete(result);
|
||||
|
|
@ -547,7 +566,7 @@ export const workspaceActions = {
|
|||
return expectQueueResponse<TaskRecord>(
|
||||
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
|
||||
wait: true,
|
||||
timeout: 12 * 60_000,
|
||||
timeout: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -604,8 +623,21 @@ export const workspaceActions = {
|
|||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||
});
|
||||
const task = await requireWorkbenchTask(c, created.taskId);
|
||||
await waitForWorkbenchTaskReady(task);
|
||||
const session = await task.createWorkbenchSession({
|
||||
taskId: created.taskId,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
});
|
||||
await task.sendWorkbenchMessage({
|
||||
taskId: created.taskId,
|
||||
tabId: session.tabId,
|
||||
text: input.task,
|
||||
attachments: [],
|
||||
});
|
||||
return {
|
||||
taskId: created.taskId,
|
||||
tabId: session.tabId,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue