Add full Docker image defaults, fix actor deadlocks, and improve dev experience

- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 18:31:55 -07:00
parent 58c54156f1
commit 110e969f98
29 changed files with 804 additions and 283 deletions

View file

@ -144,14 +144,9 @@ export const task = actor({
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
const self = selfTask(c);
const result = await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: true,
timeout: 30 * 60_000,
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
wait: false,
});
const response = expectQueueResponse<{ ok: boolean; error?: string }>(result);
if (!response.ok) {
throw new Error(response.error ?? "task provisioning failed");
}
return { ok: true };
},
@ -180,47 +175,35 @@ export const task = actor({
async push(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
wait: true,
timeout: 180_000,
wait: false,
});
},
async sync(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
wait: true,
timeout: 30_000,
wait: false,
});
},
async merge(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
wait: true,
timeout: 30_000,
wait: false,
});
},
async archive(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
void self
.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: true,
timeout: 60_000,
})
.catch((error: unknown) => {
c.log.warn({
msg: "archive command failed",
error: error instanceof Error ? error.message : String(error),
});
});
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
wait: false,
});
},
async kill(c, cmd?: TaskActionCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
wait: true,
timeout: 60_000,
wait: false,
});
},
@ -255,8 +238,7 @@ export const task = actor({
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -335,8 +317,7 @@ export const task = actor({
attachments: input.attachments,
} satisfies TaskWorkbenchSendMessageCommand,
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -344,8 +325,7 @@ export const task = actor({
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -360,8 +340,7 @@ export const task = actor({
async closeWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
@ -371,8 +350,7 @@ export const task = actor({
taskWorkflowQueueName("task.command.workbench.publish_pr"),
{},
{
wait: true,
timeout: 10 * 60_000,
wait: false,
},
);
},
@ -380,8 +358,7 @@ export const task = actor({
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
const self = selfTask(c);
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
wait: true,
timeout: 5 * 60_000,
wait: false,
});
},
},

View file

@ -6,6 +6,7 @@ import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, ge
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js";
import { getCurrentRecord } from "./workflow/common.js";
import { taskWorkflowQueueName } from "./workflow/queue.js";
const STATUS_SYNC_INTERVAL_MS = 1_000;
@ -551,9 +552,11 @@ 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).provision({ providerId });
record = await ensureWorkbenchSeeded(c);
await selfTask(c).send(taskWorkflowQueueName("task.command.provision"), { providerId }, { wait: false });
throw new Error("sandbox is provisioning — retry shortly");
}
if (record.activeSessionId) {

View file

@ -1,15 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<!--
<script src="https://unpkg.com/react-scan/dist/auto.global.js" crossorigin="anonymous"></script>
<script type="module">
if (import.meta.env.DEV) {
import("react-grab");
import("@react-grab/mcp/client");
}
</script>
-->
<script>if(window.__TAURI_INTERNALS__)document.documentElement.dataset.tauri="1"</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View file

@ -0,0 +1,301 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useStyletron } from "baseui";
import { useFoundryTokens } from "../app/theme";
import { isMockFrontendClient } from "../lib/env";
import type { TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
interface DevPanelProps {
workspaceId: string;
snapshot: TaskWorkbenchSnapshot;
}
interface TopicInfo {
label: string;
key: string;
listenerCount: number;
hasConnection: boolean;
lastRefresh: number | null;
}
function timeAgo(ts: number | null): string {
if (!ts) return "never";
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 5) return "now";
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h`;
}
function taskStatusLabel(task: WorkbenchTask): string {
if (task.status === "archived") return "archived";
const hasRunning = task.tabs?.some((tab) => tab.status === "running");
if (hasRunning) return "running";
return task.status ?? "idle";
}
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
switch (status) {
case "running":
return t.statusSuccess;
case "archived":
return t.textMuted;
case "error":
case "failed":
return t.statusError;
default:
return t.textTertiary;
}
}
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot }: DevPanelProps) {
const [css] = useStyletron();
const t = useFoundryTokens();
const [now, setNow] = useState(Date.now());
// Tick every 2s to keep relative timestamps fresh
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 2000);
return () => clearInterval(id);
}, []);
const topics = useMemo((): TopicInfo[] => {
const items: TopicInfo[] = [];
// Workbench subscription topic
items.push({
label: "Workbench",
key: `ws:${workspaceId}`,
listenerCount: 1,
hasConnection: true,
lastRefresh: 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 repos = snapshot.repos ?? [];
const projects = snapshot.projects ?? [];
const mono = css({
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
fontSize: "10px",
});
return (
<div
className={css({
position: "fixed",
bottom: "8px",
right: "8px",
width: "320px",
maxHeight: "50vh",
zIndex: 99999,
backgroundColor: t.surfaceElevated,
border: `1px solid ${t.borderMedium}`,
borderRadius: "6px",
boxShadow: t.shadow,
overflow: "hidden",
display: "flex",
flexDirection: "column",
})}
>
{/* Header */}
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 8px",
borderBottom: `1px solid ${t.borderSubtle}`,
backgroundColor: t.surfaceTertiary,
flexShrink: 0,
})}
>
<span
className={css({
fontSize: "10px",
fontWeight: 600,
color: t.textSecondary,
letterSpacing: "0.5px",
textTransform: "uppercase",
display: "flex",
alignItems: "center",
gap: "4px",
})}
>
Dev
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
</span>
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
</div>
{/* Body */}
<div className={css({ overflowY: "auto", padding: "6px" })}>
{/* Interest Topics */}
<Section label="Interest Topics" t={t} css={css}>
{topics.map((topic) => (
<div
key={topic.key}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "2px 0",
})}
>
<span
className={css({
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
flexShrink: 0,
})}
/>
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{topic.label}
</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>
</div>
))}
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
</Section>
{/* Snapshot Summary */}
<Section label="Snapshot" t={t} css={css}>
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
<Stat label="repos" value={repos.length} t={t} css={css} />
<Stat label="projects" value={projects.length} t={t} css={css} />
<Stat label="tasks" value={tasks.length} t={t} css={css} />
</div>
</Section>
{/* Tasks */}
{tasks.length > 0 && (
<Section label="Tasks" t={t} css={css}>
{tasks.slice(0, 10).map((task) => {
const status = taskStatusLabel(task);
return (
<div
key={task.id}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "1px 0",
fontSize: "10px",
})}
>
<span
className={css({
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: statusColor(status, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{task.title || task.id.slice(0, 12)}
</span>
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
</div>
);
})}
</Section>
)}
{/* Workspace */}
<Section label="Workspace" t={t} css={css}>
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
</Section>
</div>
</div>
);
});
function Section({
label,
t,
css: cssFn,
children,
}: {
label: string;
t: ReturnType<typeof useFoundryTokens>;
css: ReturnType<typeof useStyletron>[0];
children: React.ReactNode;
}) {
return (
<div className={cssFn({ marginBottom: "6px" })}>
<div
className={cssFn({
fontSize: "9px",
fontWeight: 600,
color: t.textMuted,
textTransform: "uppercase",
letterSpacing: "0.5px",
marginBottom: "2px",
})}
>
{label}
</div>
{children}
</div>
);
}
function Stat({
label,
value,
t,
css: cssFn,
}: {
label: string;
value: number;
t: ReturnType<typeof useFoundryTokens>;
css: ReturnType<typeof useStyletron>[0];
}) {
return (
<span>
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
</span>
);
}
export function useDevPanel() {
const [visible, setVisible] = useState(true);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
e.preventDefault();
setVisible((prev) => !prev);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
return visible;
}

View file

@ -16,6 +16,7 @@ import { TabStrip } from "./mock-layout/tab-strip";
import { TerminalPane } from "./mock-layout/terminal-pane";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import { DevPanel, useDevPanel } from "./dev-panel";
import {
buildDisplayMessages,
diffPath,
@ -910,6 +911,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
);
const tasks = viewModel.tasks ?? [];
const rawProjects = viewModel.projects ?? [];
const showDevPanel = useDevPanel();
const appSnapshot = useMockAppSnapshot();
const activeOrg = activeMockOrganization(appSnapshot);
const navigateToUsage = useCallback(() => {
@ -1610,6 +1612,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</div>
</div>
</div>
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
</Shell>
</>
);