mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 18:04:48 +00:00
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>
This commit is contained in:
parent
8fb19b50da
commit
098b8113f3
19 changed files with 394 additions and 130 deletions
|
|
@ -208,6 +208,8 @@ For all Rivet/RivetKit implementation:
|
||||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||||
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
||||||
|
- Never throw errors that expect the caller to retry (e.g. `throw new Error("... retry shortly")`). If a dependency is not ready, write the current state to the DB with an appropriate pending status, enqueue the async work, and return successfully. Let the client observe the pending → ready transition via push events.
|
||||||
|
- Action return contract: every action that creates a resource must write the resource record to the DB before returning, so the client can immediately query/render it. The record may have a pending status, but it must exist. Never return an ID that doesn't yet have a corresponding DB row.
|
||||||
- Actor handle policy:
|
- Actor handle policy:
|
||||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,6 @@ async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand):
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||||
const localPath = await ensureProjectReady(c);
|
|
||||||
const onBranch = cmd.onBranch?.trim() || null;
|
const onBranch = cmd.onBranch?.trim() || null;
|
||||||
const initialBranchName = onBranch;
|
const initialBranchName = onBranch;
|
||||||
const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null;
|
const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null;
|
||||||
|
|
@ -463,7 +462,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
taskId,
|
taskId,
|
||||||
repoRemote: c.state.remoteUrl,
|
repoRemote: c.state.remoteUrl,
|
||||||
repoLocalPath: localPath,
|
|
||||||
branchName: initialBranchName,
|
branchName: initialBranchName,
|
||||||
title: initialTitle,
|
title: initialTitle,
|
||||||
task: cmd.task,
|
task: cmd.task,
|
||||||
|
|
@ -954,7 +952,7 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
||||||
if (msg.name === "project.command.createTask") {
|
if (msg.name === "project.command.createTask") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-create-task",
|
name: "project-create-task",
|
||||||
timeout: 12 * 60_000,
|
timeout: 60_000,
|
||||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
|
|
@ -1020,7 +1018,7 @@ export const projectActions = {
|
||||||
return expectQueueResponse<TaskRecord>(
|
return expectQueueResponse<TaskRecord>(
|
||||||
await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, {
|
await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, {
|
||||||
wait: true,
|
wait: true,
|
||||||
timeout: 12 * 60_000,
|
timeout: 60_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export interface TaskInput {
|
||||||
repoId: string;
|
repoId: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
repoRemote: string;
|
repoRemote: string;
|
||||||
repoLocalPath: string;
|
repoLocalPath?: string;
|
||||||
branchName: string | null;
|
branchName: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
task: string;
|
task: string;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge
|
||||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||||
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
|
||||||
import { getCurrentRecord } from "./workflow/common.js";
|
import { getCurrentRecord } from "./workflow/common.js";
|
||||||
import { taskWorkflowQueueName } from "./workflow/queue.js";
|
|
||||||
|
|
||||||
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
const STATUS_SYNC_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
|
|
@ -599,7 +598,13 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
||||||
function buildSessionSummary(record: any, meta: any): any {
|
function buildSessionSummary(record: any, meta: any): any {
|
||||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
||||||
const sessionStatus =
|
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 thinkingSinceMs = meta.thinkingSinceMs ?? null;
|
||||||
let unread = Boolean(meta.unread);
|
let unread = Boolean(meta.unread);
|
||||||
if (thinkingSinceMs && sessionStatus !== "running") {
|
if (thinkingSinceMs && sessionStatus !== "running") {
|
||||||
|
|
@ -617,6 +622,7 @@ function buildSessionSummary(record: any, meta: any): any {
|
||||||
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
|
||||||
unread,
|
unread,
|
||||||
created: Boolean(meta.created || derivedSandboxSessionId),
|
created: Boolean(meta.created || derivedSandboxSessionId),
|
||||||
|
errorMessage: meta.errorMessage ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,6 +639,7 @@ function buildSessionDetailFromMeta(record: any, meta: any): any {
|
||||||
thinkingSinceMs: summary.thinkingSinceMs,
|
thinkingSinceMs: summary.thinkingSinceMs,
|
||||||
unread: summary.unread,
|
unread: summary.unread,
|
||||||
created: summary.created,
|
created: summary.created,
|
||||||
|
errorMessage: summary.errorMessage,
|
||||||
draft: {
|
draft: {
|
||||||
text: meta.draftText ?? "",
|
text: meta.draftText ?? "",
|
||||||
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
|
||||||
|
|
@ -655,7 +662,7 @@ export async function buildTaskSummary(c: any): Promise<any> {
|
||||||
id: c.state.taskId,
|
id: c.state.taskId,
|
||||||
repoId: c.state.repoId,
|
repoId: c.state.repoId,
|
||||||
title: record.title ?? "New Task",
|
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),
|
repoName: repoLabelFromRemote(c.state.repoRemote),
|
||||||
updatedAtMs: record.updatedAt,
|
updatedAtMs: record.updatedAt,
|
||||||
branch: record.branchName,
|
branch: record.branchName,
|
||||||
|
|
@ -837,14 +844,6 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
|
||||||
|
|
||||||
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
|
||||||
let record = await ensureWorkbenchSeeded(c);
|
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) {
|
if (record.activeSessionId) {
|
||||||
const existingSessions = await listSessionMetaRows(c);
|
const existingSessions = await listSessionMetaRows(c);
|
||||||
if (existingSessions.length === 0) {
|
if (existingSessions.length === 0) {
|
||||||
|
|
@ -1216,9 +1215,16 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
|
||||||
if (!record.branchName) {
|
if (!record.branchName) {
|
||||||
throw new Error("cannot publish PR without a branch");
|
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 { driver } = getActorRuntimeContext();
|
||||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
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,
|
githubToken: auth?.githubToken ?? null,
|
||||||
});
|
});
|
||||||
await c.db
|
await c.db
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||||
"task.command.provision": async (loopCtx, msg) => {
|
"task.command.provision": async (loopCtx, msg) => {
|
||||||
const body = msg.body;
|
const body = msg.body;
|
||||||
await loopCtx.removed("init-failed", "step");
|
await loopCtx.removed("init-failed", "step");
|
||||||
|
await loopCtx.removed("init-failed-v2", "step");
|
||||||
try {
|
try {
|
||||||
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
|
await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx));
|
||||||
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
|
await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx));
|
||||||
|
|
@ -107,7 +108,7 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session));
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ ok: true });
|
||||||
} catch (error) {
|
} 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({
|
await msg.complete({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: resolveErrorMessage(error),
|
error: resolveErrorMessage(error),
|
||||||
|
|
|
||||||
|
|
@ -178,8 +178,16 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||||
|
|
||||||
const { driver } = getActorRuntimeContext();
|
const { driver } = getActorRuntimeContext();
|
||||||
const auth = await resolveWorkspaceGithubAuth(loopCtx, loopCtx.state.workspaceId);
|
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 {
|
try {
|
||||||
await driver.git.fetch(loopCtx.state.repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
await driver.git.fetch(repoLocalPath, { githubToken: auth?.githubToken ?? null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logActorWarning("task.init", "fetch before naming failed", {
|
logActorWarning("task.init", "fetch before naming failed", {
|
||||||
workspaceId: loopCtx.state.workspaceId,
|
workspaceId: loopCtx.state.workspaceId,
|
||||||
|
|
@ -188,7 +196,7 @@ export async function initEnsureNameActivity(loopCtx: any): Promise<void> {
|
||||||
error: resolveErrorMessage(error),
|
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,
|
(branch: any) => branch.branchName,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
|
const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl);
|
||||||
await project.ensure({ remoteUrl });
|
|
||||||
|
|
||||||
const created = await project.createTask({
|
const created = await project.createTask({
|
||||||
task: input.task,
|
task: input.task,
|
||||||
|
|
@ -457,7 +456,7 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
||||||
if (msg.name === "workspace.command.createTask") {
|
if (msg.name === "workspace.command.createTask") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "workspace-create-task",
|
name: "workspace-create-task",
|
||||||
timeout: 12 * 60_000,
|
timeout: 60_000,
|
||||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
|
|
@ -547,7 +546,7 @@ export const workspaceActions = {
|
||||||
return expectQueueResponse<TaskRecord>(
|
return expectQueueResponse<TaskRecord>(
|
||||||
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
|
await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, {
|
||||||
wait: true,
|
wait: true,
|
||||||
timeout: 12 * 60_000,
|
timeout: 60_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import type {
|
||||||
} from "../provider-api/index.js";
|
} from "../provider-api/index.js";
|
||||||
import type { DaytonaDriver } from "../../driver.js";
|
import type { DaytonaDriver } from "../../driver.js";
|
||||||
import { Image } from "@daytonaio/sdk";
|
import { Image } from "@daytonaio/sdk";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
export interface DaytonaProviderConfig {
|
export interface DaytonaProviderConfig {
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
|
|
@ -176,6 +179,51 @@ export class DaytonaProvider implements SandboxProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shellSingleQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readLocalCodexAuth(): string | null {
|
||||||
|
const authPath = resolve(homedir(), ".codex", "auth.json");
|
||||||
|
try {
|
||||||
|
return readFileSync(authPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCloneRepoScript(req: CreateSandboxRequest, repoDir: string): string {
|
||||||
|
const usesGithubHttpAuth = req.repoRemote.startsWith("https://github.com/");
|
||||||
|
const githubPath = usesGithubHttpAuth ? req.repoRemote.slice("https://github.com/".length) : "";
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
"set -eu",
|
||||||
|
"export GIT_TERMINAL_PROMPT=0",
|
||||||
|
"export GIT_ASKPASS=/bin/echo",
|
||||||
|
`TOKEN=${JSON.stringify(req.githubToken ?? "")}`,
|
||||||
|
'if [ -z "$TOKEN" ]; then',
|
||||||
|
' if [ -n "${GH_TOKEN:-}" ]; then TOKEN="$GH_TOKEN"; else TOKEN="${GITHUB_TOKEN:-}"; fi',
|
||||||
|
"fi",
|
||||||
|
'AUTH_REMOTE=""',
|
||||||
|
...(usesGithubHttpAuth ? ['if [ -n "$TOKEN" ]; then', ` AUTH_REMOTE="https://x-access-token:${"$"}TOKEN@github.com/${githubPath}"`, "fi"] : []),
|
||||||
|
`rm -rf "${repoDir}"`,
|
||||||
|
`mkdir -p "${repoDir}"`,
|
||||||
|
`rmdir "${repoDir}"`,
|
||||||
|
// Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available.
|
||||||
|
...(usesGithubHttpAuth
|
||||||
|
? ['if [ -n "$AUTH_REMOTE" ]; then', ` git clone "$AUTH_REMOTE" "${repoDir}"`, "else", ` git clone "${req.repoRemote}" "${repoDir}"`, "fi"]
|
||||||
|
: [`git clone "${req.repoRemote}" "${repoDir}"`]),
|
||||||
|
`cd "${repoDir}"`,
|
||||||
|
...(usesGithubHttpAuth ? ['if [ -n "$AUTH_REMOTE" ]; then', ` git remote set-url origin "${req.repoRemote}"`, "fi"] : []),
|
||||||
|
// The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
||||||
|
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
|
||||||
|
`git config user.email "foundry@local" >/dev/null 2>&1 || true`,
|
||||||
|
`git config user.name "Foundry" >/dev/null 2>&1 || true`,
|
||||||
|
];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
id() {
|
id() {
|
||||||
return "daytona" as const;
|
return "daytona" as const;
|
||||||
}
|
}
|
||||||
|
|
@ -242,37 +290,7 @@ export class DaytonaProvider implements SandboxProvider {
|
||||||
});
|
});
|
||||||
|
|
||||||
const cloneStartedAt = Date.now();
|
const cloneStartedAt = Date.now();
|
||||||
await this.runCheckedCommand(
|
await this.runCheckedCommand(sandbox.id, ["bash", "-lc", this.shellSingleQuote(this.buildCloneRepoScript(req, repoDir))].join(" "), "clone repo");
|
||||||
sandbox.id,
|
|
||||||
[
|
|
||||||
"bash",
|
|
||||||
"-lc",
|
|
||||||
`${JSON.stringify(
|
|
||||||
[
|
|
||||||
"set -euo pipefail",
|
|
||||||
"export GIT_TERMINAL_PROMPT=0",
|
|
||||||
"export GIT_ASKPASS=/bin/echo",
|
|
||||||
`TOKEN=${JSON.stringify(req.githubToken ?? "")}`,
|
|
||||||
'if [ -z "$TOKEN" ]; then TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}"; fi',
|
|
||||||
"GIT_AUTH_ARGS=()",
|
|
||||||
`if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then AUTH_HEADER="$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\\n')"; GIT_AUTH_ARGS=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_HEADER"); fi`,
|
|
||||||
`rm -rf "${repoDir}"`,
|
|
||||||
`mkdir -p "${repoDir}"`,
|
|
||||||
`rmdir "${repoDir}"`,
|
|
||||||
// Foundry test repos can be private, so clone/fetch must use the sandbox's GitHub token when available.
|
|
||||||
`git "\${GIT_AUTH_ARGS[@]}" clone "${req.repoRemote}" "${repoDir}"`,
|
|
||||||
`cd "${repoDir}"`,
|
|
||||||
`if [ -n "$TOKEN" ] && [[ "${req.repoRemote}" == https://github.com/* ]]; then git config --local credential.helper ""; git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $AUTH_HEADER"; fi`,
|
|
||||||
`git "\${GIT_AUTH_ARGS[@]}" fetch origin --prune`,
|
|
||||||
// The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch).
|
|
||||||
`if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`,
|
|
||||||
`git config user.email "foundry@local" >/dev/null 2>&1 || true`,
|
|
||||||
`git config user.name "Foundry" >/dev/null 2>&1 || true`,
|
|
||||||
].join("; "),
|
|
||||||
)}`,
|
|
||||||
].join(" "),
|
|
||||||
"clone repo",
|
|
||||||
);
|
|
||||||
emitDebug("daytona.createSandbox.clone_repo.done", {
|
emitDebug("daytona.createSandbox.clone_repo.done", {
|
||||||
sandboxId: sandbox.id,
|
sandboxId: sandbox.id,
|
||||||
durationMs: Date.now() - cloneStartedAt,
|
durationMs: Date.now() - cloneStartedAt,
|
||||||
|
|
@ -357,6 +375,15 @@ export class DaytonaProvider implements SandboxProvider {
|
||||||
const sandboxAgentExports = this.buildShellExports({
|
const sandboxAgentExports = this.buildShellExports({
|
||||||
SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS: acpRequestTimeoutMs.toString(),
|
SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS: acpRequestTimeoutMs.toString(),
|
||||||
});
|
});
|
||||||
|
const codexAuth = this.readLocalCodexAuth();
|
||||||
|
const codexAuthSetup = codexAuth
|
||||||
|
? [
|
||||||
|
'mkdir -p "$HOME/.codex" "$HOME/.config/codex"',
|
||||||
|
`printf %s ${JSON.stringify(Buffer.from(codexAuth, "utf8").toString("base64"))} | base64 -d > "$HOME/.codex/auth.json"`,
|
||||||
|
'cp "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"',
|
||||||
|
"unset OPENAI_API_KEY CODEX_API_KEY",
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
await this.ensureStarted(req.sandboxId);
|
await this.ensureStarted(req.sandboxId);
|
||||||
|
|
||||||
|
|
@ -407,16 +434,16 @@ export class DaytonaProvider implements SandboxProvider {
|
||||||
[
|
[
|
||||||
"bash",
|
"bash",
|
||||||
"-lc",
|
"-lc",
|
||||||
JSON.stringify(
|
this.shellSingleQuote(
|
||||||
[
|
[
|
||||||
"set -euo pipefail",
|
"set -euo pipefail",
|
||||||
'export PATH="$HOME/.local/bin:$PATH"',
|
'export PATH="$HOME/.local/bin:$PATH"',
|
||||||
...sandboxAgentExports,
|
...sandboxAgentExports,
|
||||||
|
...codexAuthSetup,
|
||||||
"command -v sandbox-agent >/dev/null 2>&1",
|
"command -v sandbox-agent >/dev/null 2>&1",
|
||||||
"if pgrep -x sandbox-agent >/dev/null; then exit 0; fi",
|
"if pgrep -x sandbox-agent >/dev/null; then exit 0; fi",
|
||||||
'rm -f "$HOME/.codex/auth.json" "$HOME/.config/codex/auth.json"',
|
|
||||||
`nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &`,
|
`nohup sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &`,
|
||||||
].join("; "),
|
].join("\n"),
|
||||||
),
|
),
|
||||||
].join(" "),
|
].join(" "),
|
||||||
"start sandbox-agent",
|
"start sandbox-agent",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DaytonaClientLike, DaytonaDriver } from "../src/driver.js";
|
import type { DaytonaClientLike, DaytonaDriver } from "../src/driver.js";
|
||||||
import type { DaytonaCreateSandboxOptions } from "../src/integrations/daytona/client.js";
|
import type { DaytonaCreateSandboxOptions } from "../src/integrations/daytona/client.js";
|
||||||
|
|
@ -91,6 +94,10 @@ describe("daytona provider snapshot image behavior", () => {
|
||||||
const commands = client.executedCommands.join("\n");
|
const commands = client.executedCommands.join("\n");
|
||||||
expect(commands).toContain("GIT_TERMINAL_PROMPT=0");
|
expect(commands).toContain("GIT_TERMINAL_PROMPT=0");
|
||||||
expect(commands).toContain("GIT_ASKPASS=/bin/echo");
|
expect(commands).toContain("GIT_ASKPASS=/bin/echo");
|
||||||
|
expect(commands).not.toContain("[[");
|
||||||
|
expect(commands).not.toContain("GIT_AUTH_ARGS=()");
|
||||||
|
expect(commands).not.toContain("${GIT_AUTH_ARGS[@]}");
|
||||||
|
expect(commands).not.toContain(".extraheader");
|
||||||
|
|
||||||
expect(handle.metadata.snapshot).toBe("snapshot-foundry");
|
expect(handle.metadata.snapshot).toBe("snapshot-foundry");
|
||||||
expect(handle.metadata.image).toBe("ubuntu:24.04");
|
expect(handle.metadata.image).toBe("ubuntu:24.04");
|
||||||
|
|
@ -100,6 +107,11 @@ describe("daytona provider snapshot image behavior", () => {
|
||||||
|
|
||||||
it("starts sandbox-agent with ACP timeout env override", async () => {
|
it("starts sandbox-agent with ACP timeout env override", async () => {
|
||||||
const previous = process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS;
|
const previous = process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS;
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
const tempHome = resolve(tmpdir(), `daytona-provider-test-${Date.now()}`);
|
||||||
|
mkdirSync(resolve(tempHome, ".codex"), { recursive: true });
|
||||||
|
writeFileSync(resolve(tempHome, ".codex", "auth.json"), JSON.stringify({ access_token: "test-token" }));
|
||||||
|
process.env.HOME = tempHome;
|
||||||
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = "240000";
|
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = "240000";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -111,15 +123,18 @@ describe("daytona provider snapshot image behavior", () => {
|
||||||
sandboxId: "sandbox-1",
|
sandboxId: "sandbox-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
const startCommand = client.executedCommands.find((command) =>
|
const startCommand = client.executedCommands.find(
|
||||||
command.includes("nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000 sandbox-agent server"),
|
(command) => command.includes("export SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=") && command.includes("sandbox-agent server --no-token"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const joined = client.executedCommands.join("\n");
|
const joined = client.executedCommands.join("\n");
|
||||||
expect(joined).toContain("sandbox-agent/0.3.0/install.sh");
|
expect(joined).toContain("sandbox-agent/0.3.0/install.sh");
|
||||||
expect(joined).toContain("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000");
|
expect(joined).toContain("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS");
|
||||||
expect(joined).toContain("apt-get install -y nodejs npm");
|
expect(joined).toContain("apt-get install -y nodejs npm");
|
||||||
expect(joined).toContain("sandbox-agent server --no-token --host 0.0.0.0 --port 2468");
|
expect(joined).toContain("sandbox-agent server --no-token --host 0.0.0.0 --port 2468");
|
||||||
|
expect(joined).toContain('mkdir -p "$HOME/.codex" "$HOME/.config/codex"');
|
||||||
|
expect(joined).toContain("unset OPENAI_API_KEY CODEX_API_KEY");
|
||||||
|
expect(joined).not.toContain('rm -f "$HOME/.codex/auth.json"');
|
||||||
expect(startCommand).toBeTruthy();
|
expect(startCommand).toBeTruthy();
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) {
|
if (previous === undefined) {
|
||||||
|
|
@ -127,6 +142,12 @@ describe("daytona provider snapshot image behavior", () => {
|
||||||
} else {
|
} else {
|
||||||
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = previous;
|
process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = previous;
|
||||||
}
|
}
|
||||||
|
if (previousHome === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
} else {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
rmSync(tempHome, { force: true, recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export interface SandboxSessionRecord {
|
||||||
lastConnectionId: string;
|
lastConnectionId: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
destroyedAt?: number;
|
destroyedAt?: number;
|
||||||
status?: "running" | "idle" | "error";
|
status?: "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SandboxSessionEventRecord {
|
export interface SandboxSessionEventRecord {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@ import type { TopicData, TopicKey, TopicParams } from "./topics.js";
|
||||||
|
|
||||||
export type TopicStatus = "loading" | "connected" | "error";
|
export type TopicStatus = "loading" | "connected" | "error";
|
||||||
|
|
||||||
|
export interface DebugInterestTopic {
|
||||||
|
topicKey: TopicKey;
|
||||||
|
cacheKey: string;
|
||||||
|
listenerCount: number;
|
||||||
|
status: TopicStatus;
|
||||||
|
lastRefreshAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TopicState<K extends TopicKey> {
|
export interface TopicState<K extends TopicKey> {
|
||||||
data: TopicData<K> | undefined;
|
data: TopicData<K> | undefined;
|
||||||
status: TopicStatus;
|
status: TopicStatus;
|
||||||
|
|
@ -20,5 +28,6 @@ export interface InterestManager {
|
||||||
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
|
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
|
||||||
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
|
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
|
||||||
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
|
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
|
||||||
|
listDebugTopics(): DebugInterestTopic[];
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { BackendClient } from "../backend-client.js";
|
import type { BackendClient } from "../backend-client.js";
|
||||||
import type { InterestManager, TopicStatus } from "./manager.js";
|
import type { DebugInterestTopic, InterestManager, TopicStatus } from "./manager.js";
|
||||||
import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js";
|
import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js";
|
||||||
|
|
||||||
const GRACE_PERIOD_MS = 30_000;
|
const GRACE_PERIOD_MS = 30_000;
|
||||||
|
|
@ -19,7 +19,7 @@ export class RemoteInterestManager implements InterestManager {
|
||||||
let entry = this.entries.get(cacheKey);
|
let entry = this.entries.get(cacheKey);
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = new TopicEntry(definition, this.backend, params as any);
|
entry = new TopicEntry(topicKey, cacheKey, definition, this.backend, params as any);
|
||||||
this.entries.set(cacheKey, entry);
|
this.entries.set(cacheKey, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +53,13 @@ export class RemoteInterestManager implements InterestManager {
|
||||||
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null;
|
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listDebugTopics(): DebugInterestTopic[] {
|
||||||
|
return [...this.entries.values()]
|
||||||
|
.filter((entry) => entry.listenerCount > 0)
|
||||||
|
.map((entry) => entry.getDebugTopic())
|
||||||
|
.sort((left, right) => left.cacheKey.localeCompare(right.cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const entry of this.entries.values()) {
|
for (const entry of this.entries.values()) {
|
||||||
entry.dispose();
|
entry.dispose();
|
||||||
|
|
@ -66,6 +73,7 @@ class TopicEntry<TData, TParams, TEvent> {
|
||||||
status: TopicStatus = "loading";
|
status: TopicStatus = "loading";
|
||||||
error: Error | null = null;
|
error: Error | null = null;
|
||||||
listenerCount = 0;
|
listenerCount = 0;
|
||||||
|
lastRefreshAt: number | null = null;
|
||||||
|
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private conn: Awaited<ReturnType<TopicDefinition<TData, TParams, TEvent>["connect"]>> | null = null;
|
private conn: Awaited<ReturnType<TopicDefinition<TData, TParams, TEvent>["connect"]>> | null = null;
|
||||||
|
|
@ -76,11 +84,23 @@ class TopicEntry<TData, TParams, TEvent> {
|
||||||
private started = false;
|
private started = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly topicKey: TopicKey,
|
||||||
|
private readonly cacheKey: string,
|
||||||
private readonly definition: TopicDefinition<TData, TParams, TEvent>,
|
private readonly definition: TopicDefinition<TData, TParams, TEvent>,
|
||||||
private readonly backend: BackendClient,
|
private readonly backend: BackendClient,
|
||||||
private readonly params: TParams,
|
private readonly params: TParams,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
getDebugTopic(): DebugInterestTopic {
|
||||||
|
return {
|
||||||
|
topicKey: this.topicKey,
|
||||||
|
cacheKey: this.cacheKey,
|
||||||
|
listenerCount: this.listenerCount,
|
||||||
|
status: this.status,
|
||||||
|
lastRefreshAt: this.lastRefreshAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
addListener(listener: () => void): void {
|
addListener(listener: () => void): void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
this.listenerCount = this.listeners.size;
|
this.listenerCount = this.listeners.size;
|
||||||
|
|
@ -125,6 +145,7 @@ class TopicEntry<TData, TParams, TEvent> {
|
||||||
this.data = undefined;
|
this.data = undefined;
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.lastRefreshAt = null;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +161,7 @@ class TopicEntry<TData, TParams, TEvent> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.data = this.definition.applyEvent(this.data, event);
|
this.data = this.definition.applyEvent(this.data, event);
|
||||||
|
this.lastRefreshAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
});
|
});
|
||||||
this.unsubscribeError = this.conn.onError((error: unknown) => {
|
this.unsubscribeError = this.conn.onError((error: unknown) => {
|
||||||
|
|
@ -149,6 +171,7 @@ class TopicEntry<TData, TParams, TEvent> {
|
||||||
});
|
});
|
||||||
this.data = await this.definition.fetchInitial(this.backend, this.params);
|
this.data = await this.definition.fetchInitial(this.backend, this.params);
|
||||||
this.status = "connected";
|
this.status = "connected";
|
||||||
|
this.lastRefreshAt = Date.now();
|
||||||
this.started = true;
|
this.started = true;
|
||||||
this.notify();
|
this.notify();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,14 @@ describe("RemoteInterestManager", () => {
|
||||||
expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1);
|
expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1);
|
||||||
expect(manager.getStatus("workspace", params)).toBe("connected");
|
expect(manager.getStatus("workspace", params)).toBe("connected");
|
||||||
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task");
|
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task");
|
||||||
|
expect(manager.listDebugTopics()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
topicKey: "workspace",
|
||||||
|
cacheKey: "workspace:ws-1",
|
||||||
|
listenerCount: 2,
|
||||||
|
status: "connected",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
conn.emit("workspaceUpdated", {
|
conn.emit("workspaceUpdated", {
|
||||||
type: "taskSummaryUpdated",
|
type: "taskSummaryUpdated",
|
||||||
|
|
@ -123,6 +131,7 @@ describe("RemoteInterestManager", () => {
|
||||||
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task");
|
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task");
|
||||||
expect(listenerA).toHaveBeenCalled();
|
expect(listenerA).toHaveBeenCalled();
|
||||||
expect(listenerB).toHaveBeenCalled();
|
expect(listenerB).toHaveBeenCalled();
|
||||||
|
expect(manager.listDebugTopics()[0]?.lastRefreshAt).toEqual(expect.any(Number));
|
||||||
|
|
||||||
unsubscribeA();
|
unsubscribeA();
|
||||||
unsubscribeB();
|
unsubscribeB();
|
||||||
|
|
@ -140,6 +149,7 @@ describe("RemoteInterestManager", () => {
|
||||||
unsubscribeA();
|
unsubscribeA();
|
||||||
|
|
||||||
vi.advanceTimersByTime(29_000);
|
vi.advanceTimersByTime(29_000);
|
||||||
|
expect(manager.listDebugTopics()).toEqual([]);
|
||||||
|
|
||||||
const unsubscribeB = manager.subscribe("workspace", params, () => {});
|
const unsubscribeB = manager.subscribe("workspace", params, () => {});
|
||||||
await flushAsyncWork();
|
await flushAsyncWork();
|
||||||
|
|
@ -148,6 +158,7 @@ describe("RemoteInterestManager", () => {
|
||||||
expect(conn.disposeCount).toBe(0);
|
expect(conn.disposeCount).toBe(0);
|
||||||
|
|
||||||
unsubscribeB();
|
unsubscribeB();
|
||||||
|
expect(manager.listDebugTopics()).toEqual([]);
|
||||||
vi.advanceTimersByTime(30_000);
|
vi.advanceTimersByTime(30_000);
|
||||||
|
|
||||||
expect(conn.disposeCount).toBe(1);
|
expect(conn.disposeCount).toBe(1);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import { useFoundryTokens } from "../app/theme";
|
import { useFoundryTokens } from "../app/theme";
|
||||||
import { isMockFrontendClient } from "../lib/env";
|
import { isMockFrontendClient } from "../lib/env";
|
||||||
|
import { interestManager } from "../lib/interest";
|
||||||
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
||||||
|
import type { DebugInterestTopic } from "@sandbox-agent/foundry-client";
|
||||||
|
|
||||||
interface DevPanelProps {
|
interface DevPanelProps {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|
@ -15,9 +17,25 @@ interface TopicInfo {
|
||||||
key: string;
|
key: string;
|
||||||
listenerCount: number;
|
listenerCount: number;
|
||||||
hasConnection: boolean;
|
hasConnection: boolean;
|
||||||
|
status: "loading" | "connected" | "error";
|
||||||
lastRefresh: number | null;
|
lastRefresh: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function topicLabel(topic: DebugInterestTopic): string {
|
||||||
|
switch (topic.topicKey) {
|
||||||
|
case "app":
|
||||||
|
return "App";
|
||||||
|
case "workspace":
|
||||||
|
return "Workspace";
|
||||||
|
case "task":
|
||||||
|
return "Task";
|
||||||
|
case "session":
|
||||||
|
return "Session";
|
||||||
|
case "sandboxProcesses":
|
||||||
|
return "Sandbox";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function timeAgo(ts: number | null): string {
|
function timeAgo(ts: number | null): string {
|
||||||
if (!ts) return "never";
|
if (!ts) return "never";
|
||||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
|
@ -37,8 +55,11 @@ function taskStatusLabel(task: WorkbenchTask): string {
|
||||||
|
|
||||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
case "running":
|
case "running":
|
||||||
return t.statusSuccess;
|
return t.statusSuccess;
|
||||||
|
case "loading":
|
||||||
|
return t.statusWarning;
|
||||||
case "archived":
|
case "archived":
|
||||||
return t.textMuted;
|
return t.textMuted;
|
||||||
case "error":
|
case "error":
|
||||||
|
|
@ -88,33 +109,15 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const topics = useMemo((): TopicInfo[] => {
|
const topics = useMemo((): TopicInfo[] => {
|
||||||
const items: TopicInfo[] = [];
|
return interestManager.listDebugTopics().map((topic) => ({
|
||||||
|
label: topicLabel(topic),
|
||||||
// Workbench subscription topic
|
key: topic.cacheKey,
|
||||||
items.push({
|
listenerCount: topic.listenerCount,
|
||||||
label: "Workbench",
|
hasConnection: topic.status === "connected",
|
||||||
key: `ws:${workspaceId}`,
|
status: topic.status,
|
||||||
listenerCount: 1,
|
lastRefresh: topic.lastRefreshAt,
|
||||||
hasConnection: true,
|
}));
|
||||||
lastRefresh: now,
|
}, [now]);
|
||||||
});
|
|
||||||
|
|
||||||
// Per-task tab subscriptions
|
|
||||||
for (const task of snapshot.tasks ?? []) {
|
|
||||||
if (task.status === "archived") continue;
|
|
||||||
for (const tab of task.tabs ?? []) {
|
|
||||||
items.push({
|
|
||||||
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
|
|
||||||
key: `${workspaceId}:${task.id}:${tab.id}`,
|
|
||||||
listenerCount: 1,
|
|
||||||
hasConnection: tab.status === "running",
|
|
||||||
lastRefresh: tab.status === "running" ? now : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}, [workspaceId, snapshot, now]);
|
|
||||||
|
|
||||||
const tasks = snapshot.tasks ?? [];
|
const tasks = snapshot.tasks ?? [];
|
||||||
const repos = snapshot.repos ?? [];
|
const repos = snapshot.repos ?? [];
|
||||||
|
|
@ -199,6 +202,7 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
||||||
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||||
{topic.label}
|
{topic.label}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`${mono} ${css({ color: statusColor(topic.status, t) })}`}>{topic.status}</span>
|
||||||
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||||
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { Sidebar } from "./mock-layout/sidebar";
|
||||||
import { TabStrip } from "./mock-layout/tab-strip";
|
import { TabStrip } from "./mock-layout/tab-strip";
|
||||||
import { TerminalPane } from "./mock-layout/terminal-pane";
|
import { TerminalPane } from "./mock-layout/terminal-pane";
|
||||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell, SpinnerDot } from "./mock-layout/ui";
|
||||||
import { DevPanel, useDevPanel } from "./dev-panel";
|
import { DevPanel, useDevPanel } from "./dev-panel";
|
||||||
import {
|
import {
|
||||||
buildDisplayMessages,
|
buildDisplayMessages,
|
||||||
|
|
@ -88,6 +88,7 @@ function toLegacyTab(
|
||||||
thinkingSinceMs: summary.thinkingSinceMs,
|
thinkingSinceMs: summary.thinkingSinceMs,
|
||||||
unread: summary.unread,
|
unread: summary.unread,
|
||||||
created: summary.created,
|
created: summary.created,
|
||||||
|
errorMessage: summary.errorMessage ?? null,
|
||||||
draft: sessionDetail?.draft ?? {
|
draft: sessionDetail?.draft ?? {
|
||||||
text: "",
|
text: "",
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
|
@ -107,7 +108,9 @@ function toLegacyTask(
|
||||||
id: summary.id,
|
id: summary.id,
|
||||||
repoId: summary.repoId,
|
repoId: summary.repoId,
|
||||||
title: detail?.title ?? summary.title,
|
title: detail?.title ?? summary.title,
|
||||||
status: detail?.status ?? summary.status,
|
status: detail?.runtimeStatus ?? detail?.status ?? summary.status,
|
||||||
|
runtimeStatus: detail?.runtimeStatus,
|
||||||
|
statusMessage: detail?.statusMessage ?? null,
|
||||||
repoName: detail?.repoName ?? summary.repoName,
|
repoName: detail?.repoName ?? summary.repoName,
|
||||||
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
|
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
|
||||||
branch: detail?.branch ?? summary.branch,
|
branch: detail?.branch ?? summary.branch,
|
||||||
|
|
@ -117,9 +120,30 @@ function toLegacyTask(
|
||||||
diffs: detail?.diffs ?? {},
|
diffs: detail?.diffs ?? {},
|
||||||
fileTree: detail?.fileTree ?? [],
|
fileTree: detail?.fileTree ?? [],
|
||||||
minutesUsed: detail?.minutesUsed ?? 0,
|
minutesUsed: detail?.minutesUsed ?? 0,
|
||||||
|
activeSandboxId: detail?.activeSandboxId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProvisioningTaskStatus(status: string | null | undefined): boolean {
|
||||||
|
return status === "new" || String(status ?? "").startsWith("init_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
|
||||||
|
if (!tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tab.status === "pending_provision") {
|
||||||
|
return "Provisioning sandbox...";
|
||||||
|
}
|
||||||
|
if (tab.status === "pending_session_create") {
|
||||||
|
return "Creating session...";
|
||||||
|
}
|
||||||
|
if (tab.status === "error") {
|
||||||
|
return tab.errorMessage ?? "Session failed to start.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
|
function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
|
||||||
return repos
|
return repos
|
||||||
.map((repo) => ({
|
.map((repo) => ({
|
||||||
|
|
@ -202,6 +226,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
const isTerminal = task.status === "archived";
|
const isTerminal = task.status === "archived";
|
||||||
const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]);
|
const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]);
|
||||||
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
|
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
|
||||||
|
const taskProvisioning = isProvisioningTaskStatus(task.runtimeStatus ?? task.status);
|
||||||
|
const taskProvisioningMessage = task.statusMessage ?? "Provisioning sandbox...";
|
||||||
|
const activeSessionMessage = sessionStateMessage(activeAgentTab);
|
||||||
|
const showPendingSessionState =
|
||||||
|
!activeDiff &&
|
||||||
|
!!activeAgentTab &&
|
||||||
|
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
|
||||||
|
activeMessages.length === 0;
|
||||||
const draft = promptTab?.draft.text ?? "";
|
const draft = promptTab?.draft.text ?? "";
|
||||||
const attachments = promptTab?.draft.attachments ?? [];
|
const attachments = promptTab?.draft.attachments ?? [];
|
||||||
|
|
||||||
|
|
@ -619,8 +651,17 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "12px",
|
gap: "12px",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{taskProvisioning ? (
|
||||||
|
<>
|
||||||
|
<SpinnerDot size={16} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Provisioning task</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>{taskProvisioningMessage}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
|
||||||
<p style={{ margin: 0, opacity: 0.75 }}>Sessions are where you chat with the agent. Start one now to send the first prompt on this task.</p>
|
<p style={{ margin: 0, opacity: 0.75 }}>Sessions are where you chat with the agent. Start one now to send the first prompt on this task.</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -639,6 +680,59 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
>
|
>
|
||||||
New session
|
New session
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollBody>
|
||||||
|
) : showPendingSessionState ? (
|
||||||
|
<ScrollBody>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "420px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeAgentTab?.status === "error" ? null : <SpinnerDot size={16} />}
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>
|
||||||
|
{activeAgentTab?.status === "pending_provision"
|
||||||
|
? "Provisioning sandbox"
|
||||||
|
: activeAgentTab?.status === "pending_session_create"
|
||||||
|
? "Creating session"
|
||||||
|
: "Session unavailable"}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>{activeSessionMessage}</p>
|
||||||
|
{activeAgentTab?.status === "error" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTab}
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
border: 0,
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "10px 18px",
|
||||||
|
background: t.borderMedium,
|
||||||
|
color: t.textPrimary,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry session
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollBody>
|
</ScrollBody>
|
||||||
|
|
@ -658,7 +752,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
/>
|
/>
|
||||||
</ScrollBody>
|
</ScrollBody>
|
||||||
)}
|
)}
|
||||||
{!isTerminal && promptTab ? (
|
{!isTerminal && promptTab && (promptTab.status === "ready" || promptTab.status === "running" || promptTab.status === "idle") ? (
|
||||||
<PromptComposer
|
<PromptComposer
|
||||||
draft={draft}
|
draft={draft}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,10 @@ export const Sidebar = memo(function Sidebar({
|
||||||
const isActive = task.id === activeId;
|
const isActive = task.id === activeId;
|
||||||
const isDim = task.status === "archived";
|
const isDim = task.status === "archived";
|
||||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||||
|
const isProvisioning =
|
||||||
|
String(task.status).startsWith("init_") ||
|
||||||
|
task.status === "new" ||
|
||||||
|
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
|
||||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||||
|
|
@ -592,7 +596,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<TaskIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
|
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||||
</div>
|
</div>
|
||||||
<LabelSmall
|
<LabelSmall
|
||||||
$style={{
|
$style={{
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,21 @@ export const UnreadDot = memo(function UnreadDot() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TaskIndicator = memo(function TaskIndicator({ isRunning, hasUnread, isDraft }: { isRunning: boolean; hasUnread: boolean; isDraft: boolean }) {
|
export const TaskIndicator = memo(function TaskIndicator({
|
||||||
|
isRunning,
|
||||||
|
isProvisioning,
|
||||||
|
hasUnread,
|
||||||
|
isDraft,
|
||||||
|
}: {
|
||||||
|
isRunning: boolean;
|
||||||
|
isProvisioning: boolean;
|
||||||
|
hasUnread: boolean;
|
||||||
|
isDraft: boolean;
|
||||||
|
}) {
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
|
|
||||||
if (isRunning) return <SpinnerDot size={8} />;
|
if (isRunning) return <SpinnerDot size={8} />;
|
||||||
|
if (isProvisioning) return <SpinnerDot size={8} />;
|
||||||
if (hasUnread) return <UnreadDot />;
|
if (hasUnread) return <UnreadDot />;
|
||||||
if (isDraft) return <GitPullRequestDraft size={12} color={t.textSecondary} />;
|
if (isDraft) return <GitPullRequestDraft size={12} color={t.textSecondary} />;
|
||||||
return <GitPullRequest size={12} color={t.statusSuccess} />;
|
return <GitPullRequest size={12} color={t.statusSuccess} />;
|
||||||
|
|
@ -174,7 +185,7 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent:
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
|
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
|
||||||
if (tab.status === "running") return <SpinnerDot size={8} />;
|
if (tab.status === "running" || tab.status === "pending_provision" || tab.status === "pending_session_create") return <SpinnerDot size={8} />;
|
||||||
if (tab.unread) return <UnreadDot />;
|
if (tab.unread) return <UnreadDot />;
|
||||||
return <AgentIcon agent={tab.agent} size={13} />;
|
return <AgentIcon agent={tab.agent} size={13} />;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@ const AGENT_OPTIONS: SelectItem[] = [
|
||||||
|
|
||||||
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
|
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
|
||||||
if (status === "running") return "positive";
|
if (status === "running") return "positive";
|
||||||
if (status === "new") return "warning";
|
if (status === "error") return "negative";
|
||||||
|
if (status === "new" || String(status).startsWith("init_")) return "warning";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,6 +498,10 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]);
|
||||||
|
const isPendingProvision = selectedSessionSummary?.status === "pending_provision";
|
||||||
|
const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create";
|
||||||
|
const isSessionError = selectedSessionSummary?.status === "error";
|
||||||
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
||||||
|
|
||||||
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
||||||
|
|
@ -1363,10 +1368,38 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
||||||
>
|
>
|
||||||
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
|
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
|
||||||
|
|
||||||
|
{selectedSessionSummary && (isPendingProvision || isPendingSessionCreate) ? (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: theme.sizing.scale300,
|
||||||
|
padding: theme.sizing.scale500,
|
||||||
|
border: `1px solid ${theme.colors.borderOpaque}`,
|
||||||
|
backgroundColor: theme.colors.backgroundSecondary,
|
||||||
|
marginBottom: theme.sizing.scale400,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<LabelSmall marginTop="0" marginBottom="0">
|
||||||
|
{isPendingProvision ? "Provisioning sandbox..." : "Creating session..."}
|
||||||
|
</LabelSmall>
|
||||||
|
<Skeleton rows={1} height="32px" />
|
||||||
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
||||||
|
{selectedForSession?.statusMessage ?? (isPendingProvision ? "The task is still provisioning." : "The session is being created.")}
|
||||||
|
</ParagraphSmall>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
|
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
|
||||||
<EmptyState testId="session-transcript-empty">
|
<EmptyState testId="session-transcript-empty">
|
||||||
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
|
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
|
||||||
? `Session failed: ${selectedForSession.statusMessage}`
|
? `Session failed: ${selectedForSession.statusMessage}`
|
||||||
|
: isPendingProvision
|
||||||
|
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
|
||||||
|
: isPendingSessionCreate
|
||||||
|
? "Creating session..."
|
||||||
|
: isSessionError
|
||||||
|
? (selectedSessionSummary?.errorMessage ?? "Session failed to start.")
|
||||||
: !activeSandbox?.sandboxId
|
: !activeSandbox?.sandboxId
|
||||||
? selectedForSession.statusMessage
|
? selectedForSession.statusMessage
|
||||||
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
|
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
|
||||||
|
|
@ -1442,7 +1475,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
placeholder="Send a follow-up prompt to this session"
|
placeholder="Send a follow-up prompt to this session"
|
||||||
rows={5}
|
rows={5}
|
||||||
disabled={!activeSandbox?.sandboxId}
|
disabled={!activeSandbox?.sandboxId || isPendingProvision || isPendingSessionCreate || isSessionError}
|
||||||
overrides={textareaTestIdOverrides("task-session-prompt")}
|
overrides={textareaTestIdOverrides("task-session-prompt")}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1460,7 +1493,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
||||||
void sendPrompt.mutateAsync(prompt);
|
void sendPrompt.mutateAsync(prompt);
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={
|
||||||
sendPrompt.isPending || createSession.isPending || !selectedForSession || !activeSandbox?.sandboxId || draft.trim().length === 0
|
sendPrompt.isPending ||
|
||||||
|
createSession.isPending ||
|
||||||
|
!selectedForSession ||
|
||||||
|
!activeSandbox?.sandboxId ||
|
||||||
|
isPendingProvision ||
|
||||||
|
isPendingSessionCreate ||
|
||||||
|
isSessionError ||
|
||||||
|
draft.trim().length === 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -1837,7 +1877,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
|
||||||
}}
|
}}
|
||||||
data-testid="task-create-submit"
|
data-testid="task-create-submit"
|
||||||
>
|
>
|
||||||
Create Task
|
{createTask.isPending ? "Creating..." : "Create Task"}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { AgentType, ProviderId, TaskStatus } from "./contracts.js";
|
import type { AgentType, ProviderId, TaskStatus } from "./contracts.js";
|
||||||
|
|
||||||
export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived";
|
export type WorkbenchTaskStatus = TaskStatus | "new";
|
||||||
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor";
|
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor";
|
||||||
export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
||||||
|
export type WorkbenchSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error";
|
||||||
|
|
||||||
export interface WorkbenchTranscriptEvent {
|
export interface WorkbenchTranscriptEvent {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,10 +28,11 @@ export interface WorkbenchSessionSummary {
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
agent: WorkbenchAgentKind;
|
agent: WorkbenchAgentKind;
|
||||||
model: WorkbenchModelId;
|
model: WorkbenchModelId;
|
||||||
status: "running" | "idle" | "error";
|
status: WorkbenchSessionStatus;
|
||||||
thinkingSinceMs: number | null;
|
thinkingSinceMs: number | null;
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
|
errorMessage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full session content — only fetched when viewing a specific session tab. */
|
/** Full session content — only fetched when viewing a specific session tab. */
|
||||||
|
|
@ -42,10 +44,11 @@ export interface WorkbenchSessionDetail {
|
||||||
sessionName: string;
|
sessionName: string;
|
||||||
agent: WorkbenchAgentKind;
|
agent: WorkbenchAgentKind;
|
||||||
model: WorkbenchModelId;
|
model: WorkbenchModelId;
|
||||||
status: "running" | "idle" | "error";
|
status: WorkbenchSessionStatus;
|
||||||
thinkingSinceMs: number | null;
|
thinkingSinceMs: number | null;
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
created: boolean;
|
created: boolean;
|
||||||
|
errorMessage?: string | null;
|
||||||
draft: WorkbenchComposerDraft;
|
draft: WorkbenchComposerDraft;
|
||||||
transcript: WorkbenchTranscriptEvent[];
|
transcript: WorkbenchTranscriptEvent[];
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +169,8 @@ export interface WorkbenchTask {
|
||||||
repoId: string;
|
repoId: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: WorkbenchTaskStatus;
|
status: WorkbenchTaskStatus;
|
||||||
|
runtimeStatus?: TaskStatus;
|
||||||
|
statusMessage?: string | null;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
|
|
@ -175,6 +180,7 @@ export interface WorkbenchTask {
|
||||||
diffs: Record<string, string>;
|
diffs: Record<string, string>;
|
||||||
fileTree: WorkbenchFileTreeNode[];
|
fileTree: WorkbenchFileTreeNode[];
|
||||||
minutesUsed: number;
|
minutesUsed: number;
|
||||||
|
activeSandboxId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkbenchRepo {
|
export interface WorkbenchRepo {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue