Unverified 提交 b8237853 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

Add new experimental Cloud Sandbox runtime (#3177)

- **Cloud sandbox** - **docs: record session learnings** - **settings: gate cloud sandbox behind experiment** - **cloud: sync dirty paths incrementally** <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3177" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com> Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 a3ec27fc
import { expect } from "@playwright/test";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
testSkipIfWindows(
"cloud sandbox runtime mode runs previews",
async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToSettingsTab();
await po.page.getByRole("button", { name: "Experiments" }).click();
await po.settings.toggleCloudSandboxExperiment();
await po.settings.changeRuntimeMode("cloud");
expect(po.settings.recordSettings()).toMatchObject({
runtimeMode2: "cloud",
});
await po.navigation.goToAppsTab();
await po.sendPrompt("hi");
await po.previewPanel.expectPreviewIframeIsVisible(Timeout.EXTRA_LONG);
await expect(po.previewPanel.getCloudBadge()).toBeVisible({
timeout: Timeout.LONG,
});
await expect(
po.previewPanel
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Cloud Sandbox Preview" }),
).toBeVisible({ timeout: Timeout.LONG });
},
);
testSkipIfWindows(
"cloud sandbox undo restores the remote snapshot",
async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.navigation.goToSettingsTab();
await po.page.getByRole("button", { name: "Experiments" }).click();
await po.settings.toggleCloudSandboxExperiment();
await po.settings.changeRuntimeMode("cloud");
await po.navigation.goToAppsTab();
await po.sendPrompt("hi");
await po.previewPanel.expectPreviewIframeIsVisible(Timeout.EXTRA_LONG);
let iframe = po.previewPanel.getPreviewIframeElement().contentFrame();
const updatedDigestText = await iframe
.getByTestId("cloud-snapshot-digest")
.textContent({ timeout: Timeout.LONG });
const updatedDigest = updatedDigestText?.split(": ").at(-1)?.trim();
expect(updatedDigest).toBeTruthy();
await po.page.getByRole("button", { name: "Undo" }).click();
await expect
.poll(
async () => {
await po.previewPanel.clickPreviewRefresh();
iframe = po.previewPanel.getPreviewIframeElement().contentFrame();
const digestText = await iframe
.getByTestId("cloud-snapshot-digest")
.textContent({ timeout: Timeout.LONG });
return digestText?.split(": ").at(-1)?.trim();
},
{ timeout: Timeout.LONG },
)
.not.toBe(updatedDigest);
},
);
...@@ -138,6 +138,14 @@ export class PreviewPanel { ...@@ -138,6 +138,14 @@ export class PreviewPanel {
await this.page.getByTestId("preview-open-browser-button").click(); await this.page.getByTestId("preview-open-browser-button").click();
} }
async clickCopyShareableLink() {
await this.page.getByTestId("preview-copy-shareable-link-button").click();
}
getCloudBadge() {
return this.page.getByTestId("preview-cloud-badge");
}
async clickPreviewAnnotatorButton() { async clickPreviewAnnotatorButton() {
await this.page await this.page
.getByTestId("preview-annotator-button") .getByTestId("preview-annotator-button")
...@@ -169,9 +177,9 @@ export class PreviewPanel { ...@@ -169,9 +177,9 @@ export class PreviewPanel {
return this.page.getByTestId("preview-iframe-element"); return this.page.getByTestId("preview-iframe-element");
} }
expectPreviewIframeIsVisible() { expectPreviewIframeIsVisible(timeout = Timeout.LONG) {
return expect(this.getPreviewIframeElement()).toBeVisible({ return expect(this.getPreviewIframeElement()).toBeVisible({
timeout: Timeout.LONG, timeout,
}); });
} }
......
...@@ -36,6 +36,12 @@ export class Settings { ...@@ -36,6 +36,12 @@ export class Settings {
.click(); .click();
} }
async toggleCloudSandboxExperiment() {
await this.page
.getByRole("switch", { name: "Enable Cloud Sandbox" })
.click();
}
async toggleEnableSelectAppFromHomeChatInput() { async toggleEnableSelectAppFromHomeChatInput() {
await this.page await this.page
.getByRole("switch", { .getByRole("switch", {
...@@ -55,6 +61,20 @@ export class Settings { ...@@ -55,6 +61,20 @@ export class Settings {
.click(); .click();
} }
async changeRuntimeMode(mode: "host" | "docker" | "cloud") {
await this.page.getByRole("combobox", { name: "Runtime Mode" }).click();
await this.page
.getByRole("option", {
name:
mode === "host"
? "Local (default)"
: mode === "docker"
? "Docker (experimental)"
: "Cloud Sandbox (Pro)",
})
.click();
}
async clickTelemetryAccept() { async clickTelemetryAccept() {
await this.page.getByTestId("telemetry-accept-button").click(); await this.page.getByTestId("telemetry-accept-button").click();
} }
......
# Sandbox Engine Implementation Plan
> Drafted on 2026-03-13
## Summary
This document scopes the Dyad Engine work needed to support the new cloud sandbox runtime mode in the desktop app.
The desktop app now has a client-side cloud execution path:
- `runtimeMode2: "cloud"`
- sandbox provisioning via Dyad Engine
- remote preview proxying through the local Dyad proxy
- shareable preview links in the preview toolbar
- batched file sync for `editAppFile`
- E2E coverage against a fake engine
What remains is the real backend implementation: authenticated sandbox lifecycle management, file upload, log streaming, usage limits, and cleanup.
## Goals
- Provide a stable Dyad Engine API for cloud sandbox creation and teardown.
- Keep provider-specific details out of the desktop app.
- Enforce Dyad Pro access and usage limits server-side.
- Preserve Dyad’s current preview model:
- proxied URL for the iframe
- direct URL for sharing and opening externally
- Make failures explicit and actionable.
## Non-Goals
- Supporting multiple sandbox providers in v1
- Persisting sandboxes long-term across devices
- Billing dashboards or detailed usage analytics
- Environment variable passthrough for arbitrary app secrets
- Production deployment concerns beyond preview sandboxes
## Current Client Contract
The desktop app currently expects these Dyad Engine endpoints under `DYAD_ENGINE_URL`:
- `POST /sandboxes`
- `DELETE /sandboxes/:sandboxId`
- `POST /sandboxes/:sandboxId/files`
- `GET /sandboxes/:sandboxId/logs`
Current response expectations:
### `POST /sandboxes`
Request body:
```json
{
"appId": 123,
"appPath": "/abs/path/to/app",
"installCommand": "pnpm install",
"startCommand": "pnpm run dev --port 4123"
}
```
Response body:
```json
{
"sandboxId": "sbx_123",
"previewUrl": "https://sandbox-preview.example.com/sbx_123"
}
```
### `POST /sandboxes/:sandboxId/files`
Request body:
```json
{
"files": {
"src/App.tsx": "export default function App() { return <div>Hello</div>; }"
}
}
```
Response body:
```json
{
"previewUrl": "https://sandbox-preview.example.com/sbx_123"
}
```
### `GET /sandboxes/:sandboxId/logs`
- SSE response
- `data: {"message":"..."}` events
- terminates with `data: [DONE]`
## Recommended Engine Architecture
### 1. Sandbox Service Layer
Add an engine-side sandbox service with a narrow interface:
```ts
interface SandboxService {
create(input: CreateSandboxInput): Promise<CreateSandboxResult>;
uploadFiles(
input: UploadSandboxFilesInput,
): Promise<UploadSandboxFilesResult>;
streamLogs(sandboxId: string): AsyncIterable<SandboxLogEvent>;
destroy(sandboxId: string): Promise<void>;
reconcileForUser(userId: string): Promise<ReconcileResult>;
}
```
This service should own:
- provider API calls
- sandbox metadata persistence
- ownership checks
- idle timeout tracking
- per-user quota enforcement
### 2. Provider Adapter
Start with a single Vercel-backed adapter behind the service:
```ts
interface SandboxProvider {
createSandbox(...): Promise<...>;
uploadFiles(...): Promise<...>;
streamLogs(...): AsyncIterable<...>;
destroySandbox(...): Promise<void>;
}
```
Even with one provider, keep this boundary. It aligns with Dyad’s backend-flexible principle and avoids leaking Vercel specifics into route handlers.
### 3. Metadata Store
Store minimal sandbox metadata in the engine:
- `sandboxId`
- `providerSandboxId`
- `userId`
- `appId`
- `status`
- `previewUrl`
- `createdAt`
- `lastActiveAt`
- `expiresAt`
This can live in the engine database or another lightweight persistent store. Persistence is needed for:
- limit checks
- orphan cleanup
- idle hibernation
- restart reconciliation
## API Plan
### Phase 1: Core Endpoints
Implement:
- `POST /sandboxes`
- `DELETE /sandboxes/:sandboxId`
- `POST /sandboxes/:sandboxId/files`
- `GET /sandboxes/:sandboxId/logs`
Requirements:
- bearer auth using Dyad Pro credentials
- reject non-Pro users with a clear 403
- validate ownership on every sandbox-scoped route
- map provider failures to stable error codes/messages
### Phase 2: Status and Reconciliation
Implement:
- `GET /sandboxes/:sandboxId/status`
- `POST /sandboxes/reconcile`
`reconcile` should:
- find stale sandboxes owned by the current user
- destroy or mark them expired
- return a count and list of cleaned-up sandbox IDs
### Phase 3: Limits and Lifecycle
Enforce:
- max 1 active sandbox per user in v1
- 15-minute inactivity timeout
- explicit destroy on desktop stop/restart
- periodic cleanup job for abandoned sandboxes
## Request Validation
Server-side validation should include:
- `appId` must be numeric
- commands must be bounded in length
- file upload payload size limits
- file path normalization
- no absolute paths in uploaded file maps
- no path traversal segments
For file uploads, normalize and reject:
- `../foo`
- `/etc/passwd`
- empty paths
## Error Model
Use stable structured errors so the desktop app can classify them later:
```json
{
"code": "sandbox_limit_reached",
"message": "You already have an active cloud sandbox."
}
```
Suggested codes:
- `sandbox_auth_required`
- `sandbox_pro_required`
- `sandbox_limit_reached`
- `sandbox_not_found`
- `sandbox_not_owned`
- `sandbox_provider_unavailable`
- `sandbox_create_failed`
- `sandbox_upload_failed`
- `sandbox_log_stream_failed`
- `sandbox_timeout`
## Logging and Observability
Record at minimum:
- sandbox create/destroy requests
- provider latency
- file upload counts and payload sizes
- log stream open/close/error
- quota rejections
- cleanup job actions
Add correlation fields:
- `userId`
- `sandboxId`
- `providerSandboxId`
- `appId`
- request ID
## Security Notes
- Never expose provider credentials to the desktop app.
- Treat uploaded code as untrusted input.
- Lock all sandbox mutations to the authenticated user.
- Apply payload size limits and request rate limits.
- Ensure direct preview URLs are scoped to the sandbox and not reusable across users unintentionally.
## Rollout Plan
### Step 1
Ship engine endpoints behind a feature flag or allowlist.
### Step 2
Connect a staging desktop build to staging engine and validate:
- create
- upload
- preview
- copy link
- restart
- stop
- idle cleanup
### Step 3
Turn on for internal users first, then a small Dyad Pro cohort.
## Testing Plan
### Unit Tests
- route validation
- ownership checks
- quota enforcement
- timeout calculation
- error mapping
### Integration Tests
- create sandbox then upload files
- create second sandbox for same user and verify limit rejection
- destroy sandbox and recreate successfully
- SSE log stream formatting and termination
### Manual / Staging Checks
- preview URL is reachable directly
- desktop proxy still injects expected scripts
- file sync updates the running sandbox
- destroying a sandbox invalidates future file uploads/log streams
## Open Questions
- Do we want `POST /sandboxes` to accept an initial file batch to reduce round trips?
- Should `logs` remain SSE, or is WebSocket materially better for the provider integration?
- Do we want “hibernate” semantics distinct from “destroy,” or is destroy sufficient for v1?
- Should preview URLs be public-by-link or signed/expiring?
- Where should sandbox metadata live in the engine stack?
## Suggested Next Task
Implement the engine routes with a single provider adapter and a persisted sandbox metadata table, then point a staging desktop build at that environment for end-to-end validation.
# Sandbox Gaps
> Remaining gaps in the current cloud sandbox implementation as of 2026-03-13
This document records what still looks meaningfully incomplete after wiring full-app snapshot sync, version restore/checkout sync, cloud restart behavior for AI edits, and startup reconciliation.
## 1. Env var changes still do not guarantee a cloud process restart
`.env.local` and related env writes now trigger a cloud snapshot sync, but they do not force a cloud app restart.
That means:
- file contents in the remote sandbox update
- the already-running cloud process may still keep old environment values
For env changes, “snapshot synced” is not the same as “runtime config applied”.
## 2. Interactive prompt handling is still unsupported in cloud mode
`respondToAppInput` still assumes a local process with `stdin`.
For cloud sandboxes:
- there is no stdin bridge
- cloud log streaming is not translated into Dyad `input-requested` events
Any remote process that asks an interactive question will still not participate correctly in the existing prompt/response UX.
## 3. Engine-side lifecycle policy is still thin
Desktop now uses a 10-minute idle GC to match local behavior, and it can ask the engine to reconcile stale sandboxes on startup.
What still does not exist on the engine contract side:
- real concurrent sandbox enforcement
- authoritative idle expiry / hibernation semantics
- richer sandbox state transitions
- structured ownership / quota enforcement
So the desktop flow works, but lifecycle policy is still mostly client-driven.
## 4. Quit/crash cleanup still depends on later reconciliation
Normal stop/restart paths now destroy cloud sandboxes, but crash/forced-quit cases can still orphan them until reconciliation runs.
That is acceptable as a fallback, but it is still weaker than server-enforced expiry and ownership cleanup.
## 5. Address bar path still reflects the proxy URL
The preview toolbar still derives the displayed path from the proxied iframe URL, not from the canonical direct sandbox URL.
So the current UI still leaks proxy routing details rather than showing the pure sandbox path model from the original plan.
## 6. Cloud-specific error and loading UX is still minimal
The current UI has:
- cloud runtime selection
- cloud badge
- shareable link copy
It still lacks dedicated UX for:
- provisioning phases
- timeout/auth/quota failures
- reconcile/cleanup notifications
- better cloud-specific recovery actions
## 7. Provider contract is still too minimal for production
The current desktop-side provider contract is basically:
- create
- upload full snapshot
- stream logs
- destroy
- reconcile
Still likely missing for production use:
- structured error codes
- sandbox status inspection
- explicit restart / hibernate / wake operations
- env-specific mutation semantics
- better metadata for ownership and auditing
## 8. Local `appPath` is still sent to the engine
The create request still sends the local absolute app path.
That is not required for the general remote execution model and leaks local machine structure unnecessarily.
## 9. Coverage is still not broad enough
Coverage is better now. There is cloud E2E coverage for:
- shareable link
- remote snapshot change after AI edits
- undo causing the remote snapshot to change
Still missing targeted coverage for:
- version checkout / version pane flows in cloud mode
- env var changes in cloud mode
- visual editing sync in cloud mode
- local agent file-tool sync in cloud mode
- startup reconciliation behavior
- cloud-specific error states
## Recommended follow-up order
1. Force a cloud restart after env var writes, or add a real engine-side env update primitive.
2. Decide the engine-side lifecycle contract for quotas, idle expiry, and hibernation.
3. Add a cloud stdin/input-request bridge if interactive apps matter.
4. Add structured cloud error codes and map them to dedicated UI states.
5. Expand E2E coverage for the remaining cloud-specific workflows.
...@@ -110,6 +110,10 @@ git push --force origin HEAD ...@@ -110,6 +110,10 @@ git push --force origin HEAD
**Note:** Plain `--force` can overwrite others' remote commits. Only use this in the split-remote scenario described above, where `--force-with-lease` cannot work. In normal setups, always prefer `--force-with-lease`. **Note:** Plain `--force` can overwrite others' remote commits. Only use this in the split-remote scenario described above, where `--force-with-lease` cannot work. In normal setups, always prefer `--force-with-lease`.
## Repo allowlist push fallback
In some Codex shells, pushing to fork remotes can fail immediately with `Repo <owner>/<repo> is not allowlisted` even when `gh auth status` shows a valid token. If both fork remotes are blocked this way but `upstream` is allowed, push the branch directly to `upstream` (for example `git push --force-with-lease upstream HEAD:<branch>`) and then repoint the local branch to track `upstream/<branch>` so later status and push commands reflect the real remote.
## Rebase workflow and conflict resolution ## Rebase workflow and conflict resolution
### Handling unstaged changes during rebase ### Handling unstaged changes during rebase
......
...@@ -209,6 +209,8 @@ function TitleBarActions() { ...@@ -209,6 +209,8 @@ function TitleBarActions() {
const { t } = useTranslation("home"); const { t } = useTranslation("home");
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const { restartApp, refreshAppIframe } = useRunApp(); const { restartApp, refreshAppIframe } = useRunApp();
const { settings } = useSettings();
const isCloudSandboxMode = settings?.runtimeMode2 === "cloud";
const onCleanRestart = useCallback(() => { const onCleanRestart = useCallback(() => {
restartApp({ removeNodeModules: true }); restartApp({ removeNodeModules: true });
...@@ -235,6 +237,10 @@ function TitleBarActions() { ...@@ -235,6 +237,10 @@ function TitleBarActions() {
clearSessionData(); clearSessionData();
}, [clearSessionData]); }, [clearSessionData]);
const onRecreateSandbox = useCallback(() => {
restartApp({ recreateSandbox: true });
}, [restartApp]);
return ( return (
<div <div
className="flex items-center gap-0.5 no-app-region-drag mr-2" className="flex items-center gap-0.5 no-app-region-drag mr-2"
...@@ -266,6 +272,17 @@ function TitleBarActions() { ...@@ -266,6 +272,17 @@ function TitleBarActions() {
</span> </span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
{isCloudSandboxMode && (
<DropdownMenuItem onClick={onRecreateSandbox}>
<Cog size={16} />
<div className="flex flex-col">
<span>Recreate Sandbox</span>
<span className="text-xs text-muted-foreground">
Destroys the current sandbox and creates a new one
</span>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
......
import { atom } from "jotai"; import { atom } from "jotai";
import type { App, Version, ConsoleEntry } from "@/ipc/types"; import type { App, Version, ConsoleEntry } from "@/ipc/types";
import type { UserSettings } from "@/lib/schemas"; import type { RuntimeMode2, UserSettings } from "@/lib/schemas";
export const currentAppAtom = atom<App | null>(null); export const currentAppAtom = atom<App | null>(null);
export const selectedAppIdAtom = atom<number | null>(null); export const selectedAppIdAtom = atom<number | null>(null);
...@@ -18,9 +18,19 @@ export const selectedVersionIdAtom = atom<string | null>(null); ...@@ -18,9 +18,19 @@ export const selectedVersionIdAtom = atom<string | null>(null);
export const appConsoleEntriesAtom = atom<ConsoleEntry[]>([]); export const appConsoleEntriesAtom = atom<ConsoleEntry[]>([]);
export const appUrlAtom = atom< export const appUrlAtom = atom<
| { appUrl: string; appId: number; originalUrl: string } | {
| { appUrl: null; appId: null; originalUrl: null } appUrl: string;
>({ appUrl: null, appId: null, originalUrl: null }); appId: number;
originalUrl: string;
mode: RuntimeMode2;
}
| {
appUrl: null;
appId: null;
originalUrl: null;
mode: null;
}
>({ appUrl: null, appId: null, originalUrl: null, mode: null });
export const userSettingsAtom = atom<UserSettings | null>(null); export const userSettingsAtom = atom<UserSettings | null>(null);
// Atom for storing allow-listed environment variables // Atom for storing allow-listed environment variables
...@@ -33,5 +43,6 @@ export const previewPanelKeyAtom = atom<number>(0); ...@@ -33,5 +43,6 @@ export const previewPanelKeyAtom = atom<number>(0);
export const previewCurrentUrlAtom = atom<Record<number, string>>({}); export const previewCurrentUrlAtom = atom<Record<number, string>>({});
export const previewErrorMessageAtom = atom< export const previewErrorMessageAtom = atom<
{ message: string; source: "preview-app" | "dyad-app" } | undefined | { message: string; source: "preview-app" | "dyad-app" | "dyad-sync" }
| undefined
>(undefined); >(undefined);
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useSettings } from "@/hooks/useSettings";
export function CloudSandboxExperimentSwitch() {
const { settings, updateSettings } = useSettings();
const isEnabled = !!settings?.experiments?.enableCloudSandbox;
const isCloudModeActive = settings?.runtimeMode2 === "cloud";
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Switch
id="enable-cloud-sandbox-experiment"
aria-label="Enable Cloud Sandbox"
checked={isEnabled}
onCheckedChange={(checked) => {
updateSettings({
experiments: {
...settings?.experiments,
enableCloudSandbox: checked,
},
});
}}
/>
<Label htmlFor="enable-cloud-sandbox-experiment">
Enable Cloud Sandbox (Pro)
</Label>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Run your app on the Cloud (more secure and uses less local system
resources. Note: using Cloud resources consumes Pro credits)
</div>
{!isEnabled && isCloudModeActive && (
<div className="rounded bg-amber-50 p-2 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300">
Cloud Sandbox is still active for the current app. Switch the runtime
mode back to Local to fully turn it off.
</div>
)}
</div>
);
}
import { describe, expect, it } from "vitest";
import { shouldShowCloudSandboxOption } from "./RuntimeModeSelector";
describe("shouldShowCloudSandboxOption", () => {
it("hides cloud sandbox when the experiment is off and cloud is not active", () => {
expect(
shouldShowCloudSandboxOption({
runtimeMode: "host",
cloudSandboxExperimentEnabled: false,
}),
).toBe(false);
});
it("shows cloud sandbox when the experiment is enabled", () => {
expect(
shouldShowCloudSandboxOption({
runtimeMode: "host",
cloudSandboxExperimentEnabled: true,
}),
).toBe(true);
});
it("keeps cloud sandbox visible when cloud mode is already active", () => {
expect(
shouldShowCloudSandboxOption({
runtimeMode: "cloud",
cloudSandboxExperimentEnabled: false,
}),
).toBe(true);
});
});
...@@ -7,21 +7,57 @@ import { ...@@ -7,21 +7,57 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { useAtomValue } from "jotai";
import { appUrlAtom } from "@/atoms/appAtoms";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { RuntimeMode2 } from "@/lib/schemas";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export function shouldShowCloudSandboxOption({
runtimeMode,
cloudSandboxExperimentEnabled,
}: {
runtimeMode: RuntimeMode2;
cloudSandboxExperimentEnabled: boolean;
}) {
return cloudSandboxExperimentEnabled || runtimeMode === "cloud";
}
export function RuntimeModeSelector() { export function RuntimeModeSelector() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { t } = useTranslation("settings"); const { t } = useTranslation(["settings", "common"]);
const { userBudget } = useUserBudgetInfo();
const currentAppUrl = useAtomValue(appUrlAtom);
const [pendingRuntimeMode, setPendingRuntimeMode] =
useState<RuntimeMode2 | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
if (!settings) { if (!settings) {
return null; return null;
} }
const isDockerMode = settings?.runtimeMode2 === "docker"; const isDockerMode = settings?.runtimeMode2 === "docker";
const isCloudMode = settings?.runtimeMode2 === "cloud";
const hasCloudSandboxAccess = Boolean(userBudget);
const showCloudSandboxOption = shouldShowCloudSandboxOption({
runtimeMode: settings.runtimeMode2 ?? "host",
cloudSandboxExperimentEnabled: !!settings.experiments?.enableCloudSandbox,
});
const handleRuntimeModeChange = async (value: "host" | "docker") => { const applyRuntimeModeChange = async (value: RuntimeMode2) => {
try { try {
await updateSettings({ runtimeMode2: value }); await updateSettings({ runtimeMode2: value });
} catch (error: any) { } catch (error: any) {
...@@ -29,6 +65,23 @@ export function RuntimeModeSelector() { ...@@ -29,6 +65,23 @@ export function RuntimeModeSelector() {
} }
}; };
const handleRuntimeModeChange = (value: RuntimeMode2) => {
if (
value === "cloud" &&
(!hasCloudSandboxAccess || !showCloudSandboxOption)
) {
return;
}
if (currentAppUrl.appUrl && value !== (settings.runtimeMode2 ?? "host")) {
setPendingRuntimeMode(value);
setIsConfirmDialogOpen(true);
return;
}
void applyRuntimeModeChange(value);
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1"> <div className="space-y-1">
...@@ -46,6 +99,11 @@ export function RuntimeModeSelector() { ...@@ -46,6 +99,11 @@ export function RuntimeModeSelector() {
<SelectContent> <SelectContent>
<SelectItem value="host">Local (default)</SelectItem> <SelectItem value="host">Local (default)</SelectItem>
<SelectItem value="docker">Docker (experimental)</SelectItem> <SelectItem value="docker">Docker (experimental)</SelectItem>
{showCloudSandboxOption && (
<SelectItem disabled={!hasCloudSandboxAccess} value="cloud">
Cloud Sandbox (Pro)
</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
...@@ -53,6 +111,18 @@ export function RuntimeModeSelector() { ...@@ -53,6 +111,18 @@ export function RuntimeModeSelector() {
{t("general.runtimeModeDescription")} {t("general.runtimeModeDescription")}
</div> </div>
</div> </div>
{showCloudSandboxOption && !hasCloudSandboxAccess && (
<div className="text-sm text-muted-foreground bg-muted/40 p-2 rounded">
Cloud sandboxes are a Dyad Pro feature.{" "}
<button
type="button"
className="underline font-medium cursor-pointer text-primary"
onClick={() => ipc.system.openExternalUrl("https://dyad.sh/pro#ai")}
>
Upgrade to Pro
</button>
</div>
)}
{isDockerMode && ( {isDockerMode && (
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded"> <div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
⚠️ Docker mode is <b>experimental</b> and requires{" "} ⚠️ Docker mode is <b>experimental</b> and requires{" "}
...@@ -70,6 +140,45 @@ export function RuntimeModeSelector() { ...@@ -70,6 +140,45 @@ export function RuntimeModeSelector() {
to be installed and running to be installed and running
</div> </div>
)} )}
{isCloudMode && hasCloudSandboxAccess && (
<div className="text-sm text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/30 p-2 rounded">
Cloud Sandbox runs previews remotely and gives you a shareable preview
link. Note: running in cloud mode consumes Pro credits.
</div>
)}
<AlertDialog
open={isConfirmDialogOpen}
onOpenChange={(open) => {
setIsConfirmDialogOpen(open);
if (!open) {
setPendingRuntimeMode(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("general.runtimeModeSwitchTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("general.runtimeModeSwitchDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!pendingRuntimeMode) {
return;
}
void applyRuntimeModeChange(pendingRuntimeMode);
}}
>
{t("general.runtimeModeSwitchAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
...@@ -99,13 +99,26 @@ function FooterComponent({ context }: { context?: FooterContext }) { ...@@ -99,13 +99,26 @@ function FooterComponent({ context }: { context?: FooterContext }) {
const currentMessage = messages[messages.length - 1]; const currentMessage = messages[messages.length - 1];
// The user message that triggered this assistant response // The user message that triggered this assistant response
const userMessage = messages[messages.length - 2]; const userMessage = messages[messages.length - 2];
if (currentMessage?.sourceCommitHash) { const currentCommitIndex = currentMessage?.commitHash
? versions.findIndex(
(version) =>
version.oid === currentMessage.commitHash,
)
: -1;
const previousVersionId =
currentCommitIndex >= 0
? versions[currentCommitIndex + 1]?.oid
: undefined;
const revertTargetVersionId =
previousVersionId ?? currentMessage?.sourceCommitHash;
if (revertTargetVersionId) {
console.debug( console.debug(
"Reverting to source commit hash", "Reverting to previous version",
currentMessage.sourceCommitHash, revertTargetVersionId,
); );
await revertVersion({ await revertVersion({
versionId: currentMessage.sourceCommitHash, versionId: revertTargetVersionId,
currentChatMessageId: userMessage currentChatMessageId: userMessage
? { ? {
chatId: selectedChatId, chatId: selectedChatId,
......
...@@ -17,6 +17,7 @@ import { motion } from "framer-motion"; ...@@ -17,6 +17,7 @@ import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { useRunApp } from "@/hooks/useRunApp"; import { useRunApp } from "@/hooks/useRunApp";
import { useSettings } from "@/hooks/useSettings";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
...@@ -58,6 +59,8 @@ export const ActionHeader = () => { ...@@ -58,6 +59,8 @@ export const ActionHeader = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const { problemReport } = useCheckProblems(selectedAppId); const { problemReport } = useCheckProblems(selectedAppId);
const { restartApp, refreshAppIframe } = useRunApp(); const { restartApp, refreshAppIframe } = useRunApp();
const { settings } = useSettings();
const isCloudSandboxMode = settings?.runtimeMode2 === "cloud";
const isCompact = windowWidth < 888; const isCompact = windowWidth < 888;
...@@ -105,6 +108,10 @@ export const ActionHeader = () => { ...@@ -105,6 +108,10 @@ export const ActionHeader = () => {
clearSessionData(); clearSessionData();
}, [clearSessionData]); }, [clearSessionData]);
const onRecreateSandbox = useCallback(() => {
restartApp({ recreateSandbox: true });
}, [restartApp]);
// Get the problem count for the selected app // Get the problem count for the selected app
const problemCount = problemReport ? problemReport.problems.length : 0; const problemCount = problemReport ? problemReport.problems.length : 0;
...@@ -297,6 +304,17 @@ export const ActionHeader = () => { ...@@ -297,6 +304,17 @@ export const ActionHeader = () => {
</span> </span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
{isCloudSandboxMode && (
<DropdownMenuItem onClick={onRecreateSandbox}>
<Cog size={16} />
<div className="flex flex-col">
<span>{t("preview.recreateSandbox")}</span>
<span className="text-xs text-muted-foreground">
{t("preview.recreateSandboxDescription")}
</span>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
......
...@@ -7,11 +7,13 @@ import { ...@@ -7,11 +7,13 @@ import {
} from "@/atoms/appAtoms"; } from "@/atoms/appAtoms";
import { useAtomValue, useSetAtom, useAtom } from "jotai"; import { useAtomValue, useSetAtom, useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
RefreshCw, RefreshCw,
ExternalLink, ExternalLink,
Cloud,
Loader2, Loader2,
X, X,
Sparkles, Sparkles,
...@@ -71,14 +73,21 @@ import { cn } from "@/lib/utils"; ...@@ -71,14 +73,21 @@ import { cn } from "@/lib/utils";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import type { DeviceMode } from "@/lib/schemas"; import type { DeviceMode } from "@/lib/schemas";
import { queryKeys } from "@/lib/queryKeys";
import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro"; import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro";
import { useAttachments } from "@/hooks/useAttachments"; import { useAttachments } from "@/hooks/useAttachments";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { Annotator } from "@/pro/ui/components/Annotator/Annotator"; import { Annotator } from "@/pro/ui/components/Annotator/Annotator";
import { VisualEditingToolbar } from "./VisualEditingToolbar"; import { VisualEditingToolbar } from "./VisualEditingToolbar";
import { resolvePreviewBrowserUrl } from "./previewBrowserUrl";
interface ErrorBannerProps { interface ErrorBannerProps {
error: { message: string; source: "preview-app" | "dyad-app" } | undefined; error:
| {
message: string;
source: "preview-app" | "dyad-app" | "dyad-sync";
}
| undefined;
onDismiss: () => void; onDismiss: () => void;
onAIFix: () => void; onAIFix: () => void;
} }
...@@ -88,6 +97,8 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { ...@@ -88,6 +97,8 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
const { isStreaming } = useStreamChat(); const { isStreaming } = useStreamChat();
if (!error) return null; if (!error) return null;
const isDockerError = error.message.includes("Cannot connect to the Docker"); const isDockerError = error.message.includes("Cannot connect to the Docker");
const isInternalDyadError = error.source === "dyad-app";
const isSyncError = error.source === "dyad-sync";
const getTruncatedError = () => { const getTruncatedError = () => {
const firstLine = error.message.split("\n")[0]; const firstLine = error.message.split("\n")[0];
...@@ -111,10 +122,9 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { ...@@ -111,10 +122,9 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
<X size={14} className="text-red-500 dark:text-red-400" /> <X size={14} className="text-red-500 dark:text-red-400" />
</button> </button>
{/* Add a little chip that says "Internal error" if source is "dyad-app" */} {(isInternalDyadError || isSyncError) && (
{error.source === "dyad-app" && (
<div className="absolute top-1 right-1 p-1 bg-red-100 dark:bg-red-900 rounded-md text-xs font-medium text-red-700 dark:text-red-300"> <div className="absolute top-1 right-1 p-1 bg-red-100 dark:bg-red-900 rounded-md text-xs font-medium text-red-700 dark:text-red-300">
Internal Dyad error {isSyncError ? "Cloud sync issue" : "Internal Dyad error"}
</div> </div>
)} )}
...@@ -122,7 +132,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { ...@@ -122,7 +132,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
<div <div
className={cn( className={cn(
"px-6 py-1 text-sm", "px-6 py-1 text-sm",
error.source === "dyad-app" && "pt-6", (isInternalDyadError || isSyncError) && "pt-6",
)} )}
> >
<div <div
...@@ -148,7 +158,9 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { ...@@ -148,7 +158,9 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
<span className="font-medium">Tip: </span> <span className="font-medium">Tip: </span>
{isDockerError {isDockerError
? "Make sure Docker Desktop is running and try restarting the app." ? "Make sure Docker Desktop is running and try restarting the app."
: error.source === "dyad-app" : isSyncError
? "Dyad could not upload your latest local changes to the cloud sandbox. Check your network connection or wait for sync to recover."
: isInternalDyadError
? "Try restarting the Dyad app or restarting your computer to see if that fixes the error." ? "Try restarting the Dyad app or restarting your computer to see if that fixes the error."
: "Check if restarting the app fixes the error."} : "Check if restarting the app fixes the error."}
</span> </span>
...@@ -176,7 +188,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { ...@@ -176,7 +188,7 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
// Preview iframe component // Preview iframe component
export const PreviewIframe = ({ loading }: { loading: boolean }) => { export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const { appUrl, originalUrl } = useAtomValue(appUrlAtom); const { appUrl, originalUrl, mode } = useAtomValue(appUrlAtom);
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom); const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
// State to trigger iframe reload // State to trigger iframe reload
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -254,6 +266,15 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -254,6 +266,15 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
// Device mode state // Device mode state
const deviceMode: DeviceMode = settings?.previewDeviceMode ?? "desktop"; const deviceMode: DeviceMode = settings?.previewDeviceMode ?? "desktop";
const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false); const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false);
const queryClient = useQueryClient();
const {
mutateAsync: createCloudSandboxShareLink,
isPending: isCreatingCloudSandboxShareLink,
} = useMutation({
mutationFn: async ({ appId }: { appId: number }) => {
return ipc.app.createCloudSandboxShareLink({ appId });
},
});
// Device configurations // Device configurations
const deviceWidthConfig = { const deviceWidthConfig = {
...@@ -263,6 +284,85 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -263,6 +284,85 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
//detect if the user is using Mac //detect if the user is using Mac
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const isCloudMode = mode === "cloud";
const { data: cloudSandboxStatus } = useQuery({
queryKey: queryKeys.cloudSandboxes.status({ appId: selectedAppId }),
queryFn: async () => {
if (selectedAppId === null) {
return null;
}
return ipc.app.getCloudSandboxStatus({ appId: selectedAppId });
},
enabled: isCloudMode && selectedAppId !== null,
refetchInterval: 15_000,
retry: false,
});
useEffect(() => {
if (!isCloudMode || !cloudSandboxStatus) {
return;
}
if (
cloudSandboxStatus.status === "destroyed" &&
(cloudSandboxStatus.terminationReason === "credits_exhausted" ||
cloudSandboxStatus.terminationReason === "billing_unavailable" ||
cloudSandboxStatus.lastErrorCode === "sandbox_credits_exhausted" ||
cloudSandboxStatus.lastErrorCode === "sandbox_billing_unavailable")
) {
setErrorMessage({
message: cloudSandboxStatus.lastErrorMessage
? cloudSandboxStatus.lastErrorMessage.includes("Dyad stopped")
? cloudSandboxStatus.lastErrorMessage
: cloudSandboxStatus.terminationReason === "credits_exhausted"
? "This cloud sandbox was stopped because your Dyad Pro credits ran out. Add credits and start it again."
: "This cloud sandbox was stopped because Dyad could not confirm billing. Please try starting it again."
: cloudSandboxStatus.terminationReason === "credits_exhausted"
? "This cloud sandbox was stopped because your Dyad Pro credits ran out. Add credits and start it again."
: "This cloud sandbox was stopped because Dyad could not confirm billing. Please try starting it again.",
source: "dyad-app",
});
}
}, [cloudSandboxStatus, isCloudMode, setErrorMessage]);
useEffect(() => {
if (!isCloudMode || !cloudSandboxStatus) {
return;
}
const localSyncErrorMessage = cloudSandboxStatus.localSyncErrorMessage;
if (localSyncErrorMessage) {
setErrorMessage((current) =>
current && current.source !== "dyad-sync"
? current
: {
message: localSyncErrorMessage,
source: "dyad-sync",
},
);
return;
}
setErrorMessage((current) =>
current?.source === "dyad-sync" ? undefined : current,
);
}, [cloudSandboxStatus, isCloudMode, setErrorMessage]);
useEffect(() => {
if (!isCloudMode || !cloudSandboxStatus) {
return;
}
void queryClient.invalidateQueries({
queryKey: queryKeys.userBudget.info,
});
}, [
cloudSandboxStatus?.billingSlicesCharged,
cloudSandboxStatus?.terminationReason,
isCloudMode,
queryClient,
]);
const analyzeComponent = async (componentId: string) => { const analyzeComponent = async (componentId: string) => {
if (!componentId || !selectedAppId) return; if (!componentId || !selectedAppId) return;
...@@ -1128,6 +1228,23 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -1128,6 +1228,23 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
<div className="flex items-center p-2 border-b space-x-2"> <div className="flex items-center p-2 border-b space-x-2">
{/* Navigation Buttons */} {/* Navigation Buttons */}
<div className="flex space-x-1"> <div className="flex space-x-1">
{isCloudMode && (
<Tooltip>
<TooltipTrigger
render={
<div
aria-label="Running in a cloud sandbox"
className="flex items-center rounded-full bg-sky-100 px-2 py-1 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300"
data-testid="preview-cloud-badge"
role="status"
/>
}
>
<Cloud size={14} />
</TooltipTrigger>
<TooltipContent>Running in a Cloud sandbox</TooltipContent>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
...@@ -1289,17 +1406,36 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => { ...@@ -1289,17 +1406,36 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
} }
> >
<Power size={16} /> <Power size={16} />
<span>Restart</span> <span>{isCloudMode ? "Restart Sandbox" : "Restart"}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Restart App</TooltipContent> <TooltipContent>
{isCloudMode ? "Restart Cloud Sandbox" : "Restart App"}
</TooltipContent>
</Tooltip> </Tooltip>
<button <button
data-testid="preview-open-browser-button" data-testid="preview-open-browser-button"
onClick={() => { onClick={async () => {
if (originalUrl) { try {
ipc.system.openExternalUrl(originalUrl); const url = await resolvePreviewBrowserUrl({
isCloudMode,
selectedAppId,
originalUrl,
createCloudSandboxShareLink,
});
await ipc.system.openExternalUrl(url);
} catch (error) {
showError(
error instanceof Error
? error.message
: "Failed to open cloud sandbox share link.",
);
} }
}} }}
disabled={
isCloudMode
? selectedAppId === null || isCreatingCloudSandboxShareLink
: !originalUrl
}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300" className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
> >
<ExternalLink size={16} /> <ExternalLink size={16} />
......
import { describe, expect, it, vi } from "vitest";
import { resolvePreviewBrowserUrl } from "./previewBrowserUrl";
describe("resolvePreviewBrowserUrl", () => {
it("returns a cloud share link instead of the raw preview URL", async () => {
const createCloudSandboxShareLink = vi
.fn()
.mockResolvedValue({ url: "https://dyad.sh/share/sandbox-1" });
await expect(
resolvePreviewBrowserUrl({
isCloudMode: true,
selectedAppId: 42,
originalUrl: "https://preview.internal.test",
createCloudSandboxShareLink,
}),
).resolves.toBe("https://dyad.sh/share/sandbox-1");
expect(createCloudSandboxShareLink).toHaveBeenCalledWith({
appId: 42,
});
});
it("returns the existing preview URL for non-cloud previews", async () => {
const createCloudSandboxShareLink = vi.fn();
await expect(
resolvePreviewBrowserUrl({
isCloudMode: false,
selectedAppId: null,
originalUrl: "http://127.0.0.1:3000",
createCloudSandboxShareLink,
}),
).resolves.toBe("http://127.0.0.1:3000");
expect(createCloudSandboxShareLink).not.toHaveBeenCalled();
});
it("throws when cloud preview browser open is requested without an app id", async () => {
await expect(
resolvePreviewBrowserUrl({
isCloudMode: true,
selectedAppId: null,
originalUrl: "https://preview.internal.test",
createCloudSandboxShareLink: vi.fn(),
}),
).rejects.toThrow("Cloud sandbox is not running.");
});
});
export async function resolvePreviewBrowserUrl(input: {
isCloudMode: boolean;
selectedAppId: number | null;
originalUrl: string | null | undefined;
createCloudSandboxShareLink: (params: {
appId: number;
}) => Promise<{ url: string }>;
}): Promise<string> {
if (input.isCloudMode) {
if (input.selectedAppId === null) {
throw new Error("Cloud sandbox is not running.");
}
const shareLink = await input.createCloudSandboxShareLink({
appId: input.selectedAppId,
});
return shareLink.url;
}
if (!input.originalUrl) {
throw new Error("Preview URL is unavailable.");
}
return input.originalUrl;
}
...@@ -4,6 +4,8 @@ import { useSetAtom } from "jotai"; ...@@ -4,6 +4,8 @@ import { useSetAtom } from "jotai";
import { activeCheckoutCounterAtom } from "@/store/appAtoms"; import { activeCheckoutCounterAtom } from "@/store/appAtoms";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { useRunApp } from "./useRunApp";
import { useSettings } from "./useSettings";
interface CheckoutVersionVariables { interface CheckoutVersionVariables {
appId: number; appId: number;
...@@ -13,6 +15,8 @@ interface CheckoutVersionVariables { ...@@ -13,6 +15,8 @@ interface CheckoutVersionVariables {
export function useCheckoutVersion() { export function useCheckoutVersion() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setActiveCheckouts = useSetAtom(activeCheckoutCounterAtom); const setActiveCheckouts = useSetAtom(activeCheckoutCounterAtom);
const { restartApp } = useRunApp();
const { settings } = useSettings();
const { isPending: isCheckingOutVersion, mutateAsync: checkoutVersion } = const { isPending: isCheckingOutVersion, mutateAsync: checkoutVersion } =
useMutation<void, Error, CheckoutVersionVariables>({ useMutation<void, Error, CheckoutVersionVariables>({
...@@ -31,14 +35,17 @@ export function useCheckoutVersion() { ...@@ -31,14 +35,17 @@ export function useCheckoutVersion() {
setActiveCheckouts((prev) => prev - 1); // Decrement counter setActiveCheckouts((prev) => prev - 1); // Decrement counter
} }
}, },
onSuccess: (_, variables) => { onSuccess: async (_, variables) => {
// Invalidate queries that depend on the current version/branch // Invalidate queries that depend on the current version/branch
queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: queryKeys.branches.current({ appId: variables.appId }), queryKey: queryKeys.branches.current({ appId: variables.appId }),
}); });
queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: queryKeys.versions.list({ appId: variables.appId }), queryKey: queryKeys.versions.list({ appId: variables.appId }),
}); });
if (settings?.runtimeMode2 === "cloud") {
await restartApp();
}
}, },
meta: { showErrorToast: true }, meta: { showErrorToast: true },
}); });
......
import { renderHook, act } from "@testing-library/react";
import { createStore, Provider } from "jotai";
import type { PropsWithChildren } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
appConsoleEntriesAtom,
previewErrorMessageAtom,
selectedAppIdAtom,
} from "@/atoms/appAtoms";
import { useAppOutputSubscription } from "@/hooks/useRunApp";
const {
addLogMock,
appOutputBatchListeners,
appOutputListeners,
respondToAppInputMock,
showErrorMock,
showInputRequestMock,
} = vi.hoisted(() => ({
addLogMock: vi.fn(),
appOutputBatchListeners: new Set<(outputs: unknown[]) => void>(),
appOutputListeners: new Set<(output: unknown) => void>(),
respondToAppInputMock: vi.fn(),
showErrorMock: vi.fn(),
showInputRequestMock: vi.fn(),
}));
vi.mock("@/ipc/types", () => ({
ipc: {
app: {
respondToAppInput: respondToAppInputMock,
},
misc: {
addLog: addLogMock,
},
events: {
misc: {
onAppOutput: (listener: (output: unknown) => void) => {
appOutputListeners.add(listener);
return () => appOutputListeners.delete(listener);
},
onAppOutputBatch: (listener: (outputs: unknown[]) => void) => {
appOutputBatchListeners.add(listener);
return () => appOutputBatchListeners.delete(listener);
},
},
},
},
}));
vi.mock("@/lib/toast", () => ({
showError: showErrorMock,
showInputRequest: showInputRequestMock,
}));
function makeWrapper(appId: number) {
const store = createStore();
store.set(selectedAppIdAtom, appId);
return {
store,
Wrapper({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>;
},
};
}
describe("useAppOutputSubscription", () => {
beforeEach(() => {
vi.useFakeTimers();
addLogMock.mockReset();
appOutputListeners.clear();
appOutputBatchListeners.clear();
respondToAppInputMock.mockReset();
showErrorMock.mockReset();
showInputRequestMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows throttled sync failure toasts and clears sync errors after recovery", () => {
const { store, Wrapper } = makeWrapper(1);
const { unmount } = renderHook(() => useAppOutputSubscription(), {
wrapper: Wrapper,
});
expect(appOutputListeners.size).toBe(1);
expect(appOutputBatchListeners.size).toBe(1);
const emitOutput = (output: {
type: string;
message: string;
appId: number;
}) => {
act(() => {
for (const listener of appOutputListeners) {
listener(output);
}
});
};
emitOutput({
type: "sync-error",
message: "Cloud sandbox sync failed: network down",
appId: 1,
});
expect(showErrorMock).toHaveBeenCalledTimes(1);
expect(store.get(previewErrorMessageAtom)).toEqual({
message: "Cloud sandbox sync failed: network down",
source: "dyad-sync",
});
expect(store.get(appConsoleEntriesAtom)).toHaveLength(1);
emitOutput({
type: "sync-error",
message: "Cloud sandbox sync failed: network down",
appId: 1,
});
expect(showErrorMock).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(30_000);
});
emitOutput({
type: "sync-error",
message: "Cloud sandbox sync failed: network down",
appId: 1,
});
expect(showErrorMock).toHaveBeenCalledTimes(2);
emitOutput({
type: "sync-recovered",
message:
"Cloud sandbox sync recovered. Local changes are uploading again.",
appId: 1,
});
expect(store.get(previewErrorMessageAtom)).toBeUndefined();
expect(
store.get(appConsoleEntriesAtom).map((entry) => entry.message),
).toContain(
"Cloud sandbox sync recovered. Local changes are uploading again.",
);
unmount();
expect(appOutputListeners.size).toBe(0);
expect(appOutputBatchListeners.size).toBe(0);
});
});
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import { atom } from "jotai"; import { atom } from "jotai";
import { ipc, type AppOutput } from "@/ipc/types"; import { ipc, type AppOutput } from "@/ipc/types";
import { import {
...@@ -11,9 +11,11 @@ import { ...@@ -11,9 +11,11 @@ import {
selectedAppIdAtom, selectedAppIdAtom,
} from "@/atoms/appAtoms"; } from "@/atoms/appAtoms";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { showInputRequest } from "@/lib/toast"; import { showError, showInputRequest } from "@/lib/toast";
import type { RuntimeMode2 } from "@/lib/schemas";
const useRunAppLoadingAtom = atom(false); const useRunAppLoadingAtom = atom(false);
const CLOUD_SYNC_ERROR_TOAST_WINDOW_MS = 30_000;
/** /**
* Hook to subscribe to app output events from the main process. * Hook to subscribe to app output events from the main process.
...@@ -23,8 +25,12 @@ const useRunAppLoadingAtom = atom(false); ...@@ -23,8 +25,12 @@ const useRunAppLoadingAtom = atom(false);
export function useAppOutputSubscription() { export function useAppOutputSubscription() {
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom); const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
const [, setAppUrlObj] = useAtom(appUrlAtom); const [, setAppUrlObj] = useAtom(appUrlAtom);
const [, setPreviewErrorMessage] = useAtom(previewErrorMessageAtom);
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom); const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const syncErrorToastRef = useRef(
new Map<number, { message: string; shownAt: number }>(),
);
const processProxyServerOutput = useCallback( const processProxyServerOutput = useCallback(
(output: AppOutput) => { (output: AppOutput) => {
...@@ -37,14 +43,17 @@ export function useAppOutputSubscription() { ...@@ -37,14 +43,17 @@ export function useAppOutputSubscription() {
/\[dyad-proxy-server\]started=\[(.*?)\]/, /\[dyad-proxy-server\]started=\[(.*?)\]/,
); );
const originalUrlMatch = output.message.match(/original=\[(.*?)\]/); const originalUrlMatch = output.message.match(/original=\[(.*?)\]/);
const modeMatch = output.message.match(/mode=\[(.*?)\]/);
if (proxyUrlMatch && proxyUrlMatch[1]) { if (proxyUrlMatch && proxyUrlMatch[1]) {
const proxyUrl = proxyUrlMatch[1]; const proxyUrl = proxyUrlMatch[1];
const originalUrl = originalUrlMatch && originalUrlMatch[1]; const originalUrl = originalUrlMatch && originalUrlMatch[1];
const mode = (modeMatch?.[1] as RuntimeMode2 | undefined) ?? "host";
setAppUrlObj({ setAppUrlObj({
appUrl: proxyUrl, appUrl: proxyUrl,
appId: output.appId, appId: output.appId,
originalUrl: originalUrl!, originalUrl: originalUrl!,
mode,
}); });
} }
} }
...@@ -73,6 +82,39 @@ export function useAppOutputSubscription() { ...@@ -73,6 +82,39 @@ export function useAppOutputSubscription() {
return null; // Don't add to regular output return null; // Don't add to regular output
} }
if (output.type === "sync-error") {
const previousToast = syncErrorToastRef.current.get(output.appId);
const now = Date.now();
if (
!previousToast ||
previousToast.message !== output.message ||
now - previousToast.shownAt >= CLOUD_SYNC_ERROR_TOAST_WINDOW_MS
) {
showError(output.message);
syncErrorToastRef.current.set(output.appId, {
message: output.message,
shownAt: now,
});
}
setPreviewErrorMessage((current) =>
current && current.source !== "dyad-sync"
? current
: {
message: output.message,
source: "dyad-sync",
},
);
}
if (output.type === "sync-recovered") {
syncErrorToastRef.current.delete(output.appId);
setPreviewErrorMessage((current) =>
current?.source === "dyad-sync" ? undefined : current,
);
}
// Handle HMR updates // Handle HMR updates
if ( if (
output.message.includes("hmr update") && output.message.includes("hmr update") &&
...@@ -88,7 +130,9 @@ export function useAppOutputSubscription() { ...@@ -88,7 +130,9 @@ export function useAppOutputSubscription() {
// Server logs (stdout/stderr) are already stored in the main process // Server logs (stdout/stderr) are already stored in the main process
const logEntry = { const logEntry = {
level: level:
output.type === "stderr" || output.type === "client-error" output.type === "stderr" ||
output.type === "client-error" ||
output.type === "sync-error"
? ("error" as const) ? ("error" as const)
: ("info" as const), : ("info" as const),
type: "server" as const, type: "server" as const,
...@@ -103,7 +147,7 @@ export function useAppOutputSubscription() { ...@@ -103,7 +147,7 @@ export function useAppOutputSubscription() {
return logEntry; return logEntry;
}, },
[processProxyServerOutput, onHotModuleReload], [onHotModuleReload, processProxyServerOutput, setPreviewErrorMessage],
); );
// Subscribe to immediate app output events (input-requested) // Subscribe to immediate app output events (input-requested)
...@@ -163,7 +207,7 @@ export function useRunApp() { ...@@ -163,7 +207,7 @@ export function useRunApp() {
// Clear the URL and add restart message // Clear the URL and add restart message
setAppUrlObj((prevAppUrlObj) => { setAppUrlObj((prevAppUrlObj) => {
if (prevAppUrlObj?.appId !== appId) { if (prevAppUrlObj?.appId !== appId) {
return { appUrl: null, appId: null, originalUrl: null }; return { appUrl: null, appId: null, originalUrl: null, mode: null };
} }
return prevAppUrlObj; // No change needed return prevAppUrlObj; // No change needed
}); });
...@@ -228,7 +272,8 @@ export function useRunApp() { ...@@ -228,7 +272,8 @@ export function useRunApp() {
const restartApp = useCallback( const restartApp = useCallback(
async ({ async ({
removeNodeModules = false, removeNodeModules = false,
}: { removeNodeModules?: boolean } = {}) => { recreateSandbox = false,
}: { removeNodeModules?: boolean; recreateSandbox?: boolean } = {}) => {
if (appId === null) { if (appId === null) {
return; return;
} }
...@@ -237,11 +282,17 @@ export function useRunApp() { ...@@ -237,11 +282,17 @@ export function useRunApp() {
console.debug( console.debug(
"Restarting app", "Restarting app",
appId, appId,
recreateSandbox ? "with sandbox recreation" : "",
removeNodeModules ? "with node_modules cleanup" : "", removeNodeModules ? "with node_modules cleanup" : "",
); );
// Clear the URL and add restart message // Clear the URL and add restart message
setAppUrlObj({ appUrl: null, appId: null, originalUrl: null }); setAppUrlObj({
appUrl: null,
appId: null,
originalUrl: null,
mode: null,
});
// Clear preserved URL to prevent stale route restoration after restart // Clear preserved URL to prevent stale route restoration after restart
setPreservedUrls((prev) => { setPreservedUrls((prev) => {
...@@ -270,7 +321,7 @@ export function useRunApp() { ...@@ -270,7 +321,7 @@ export function useRunApp() {
const app = await ipc.app.getApp(appId); const app = await ipc.app.getApp(appId);
setApp(app); setApp(app);
await ipc.app.restartApp({ appId, removeNodeModules }); await ipc.app.restartApp({ appId, removeNodeModules, recreateSandbox });
} catch (error) { } catch (error) {
console.error(`Error restarting app ${appId}:`, error); console.error(`Error restarting app ${appId}:`, error);
setPreviewErrorMessage( setPreviewErrorMessage(
......
...@@ -215,8 +215,8 @@ export function useStreamChat({ ...@@ -215,8 +215,8 @@ export function useStreamChat({
} }
}, },
onEnd: (response: ChatResponseEnd) => { onEnd: (response: ChatResponseEnd) => {
// Remove from pending set now that stream is complete
pendingStreamChatIds.delete(chatId); pendingStreamChatIds.delete(chatId);
void (async () => {
// Only mark as successful if NOT cancelled - wasCancelled flag is set // Only mark as successful if NOT cancelled - wasCancelled flag is set
// by the backend when user cancels the stream // by the backend when user cancels the stream
if (response.wasCancelled) { if (response.wasCancelled) {
...@@ -294,7 +294,9 @@ export function useStreamChat({ ...@@ -294,7 +294,9 @@ export function useStreamChat({
showWarning(warningMessage); showWarning(warningMessage);
} }
// Use queryClient directly with the chatId parameter to avoid stale closure issues // Use queryClient directly with the chatId parameter to avoid stale closure issues
queryClient.invalidateQueries({ queryKey: ["proposal", chatId] }); queryClient.invalidateQueries({
queryKey: ["proposal", chatId],
});
refetchUserBudget(); refetchUserBudget();
...@@ -318,6 +320,18 @@ export function useStreamChat({ ...@@ -318,6 +320,18 @@ export function useStreamChat({
refreshVersions(); refreshVersions();
invalidateTokenCount(); invalidateTokenCount();
onSettled?.({ success: true }); onSettled?.({ success: true });
})().catch((error) => {
console.error(
`[CHAT] Failed to finalize stream for ${chatId}:`,
error,
);
setIsStreamingById((prev) => {
const next = new Map(prev);
next.set(chatId, false);
return next;
});
onSettled?.({ success: false });
});
}, },
onError: ({ error: errorMessage, warningMessages }) => { onError: ({ error: errorMessage, warningMessages }) => {
// Remove from pending set now that stream ended with error // Remove from pending set now that stream ended with error
......
...@@ -8,12 +8,16 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; ...@@ -8,12 +8,16 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { toast } from "sonner"; import { toast } from "sonner";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { useRunApp } from "./useRunApp";
import { useSettings } from "./useSettings";
export function useVersions(appId: number | null) { export function useVersions(appId: number | null) {
const [, setVersionsAtom] = useAtom(versionsListAtom); const [, setVersionsAtom] = useAtom(versionsListAtom);
const selectedChatId = useAtomValue(selectedChatIdAtom); const selectedChatId = useAtomValue(selectedChatIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { restartApp } = useRunApp();
const { settings } = useSettings();
const { const {
data: versions, data: versions,
...@@ -87,6 +91,9 @@ export function useVersions(appId: number | null) { ...@@ -87,6 +91,9 @@ export function useVersions(appId: number | null) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: queryKeys.problems.byApp({ appId }), queryKey: queryKeys.problems.byApp({ appId }),
}); });
if (settings?.runtimeMode2 === "cloud") {
await restartApp();
}
}, },
meta: { showErrorToast: true }, meta: { showErrorToast: true },
}); });
......
...@@ -205,6 +205,8 @@ ...@@ -205,6 +205,8 @@
"rebuildDescription": "Re-installs node_modules and restarts", "rebuildDescription": "Re-installs node_modules and restarts",
"clearCache": "Clear Cache", "clearCache": "Clear Cache",
"clearCacheDescription": "Clears cookies and local storage and other app cache", "clearCacheDescription": "Clears cookies and local storage and other app cache",
"recreateSandbox": "Recreate Sandbox",
"recreateSandboxDescription": "Destroys the current sandbox and creates a new one",
"loadingFiles": "Loading files...", "loadingFiles": "Loading files...",
"noAppSelected": "No app selected", "noAppSelected": "No app selected",
"refreshFiles": "Refresh Files", "refreshFiles": "Refresh Files",
......
...@@ -21,6 +21,9 @@ ...@@ -21,6 +21,9 @@
"selectReleaseChannel": "Select release channel", "selectReleaseChannel": "Select release channel",
"runtimeMode": "Runtime Mode", "runtimeMode": "Runtime Mode",
"runtimeModeDescription": "Select the runtime to use for running the app.", "runtimeModeDescription": "Select the runtime to use for running the app.",
"runtimeModeSwitchTitle": "Switch runtime mode?",
"runtimeModeSwitchDescription": "Switching runtime mode will stop your currently running app.",
"runtimeModeSwitchAction": "Switch runtime",
"selectRuntimeMode": "Select runtime mode", "selectRuntimeMode": "Select runtime mode",
"nodePath": "Node.js Path Configuration", "nodePath": "Node.js Path Configuration",
"browseForNode": "Browse for Node.js", "browseForNode": "Browse for Node.js",
......
...@@ -202,6 +202,8 @@ ...@@ -202,6 +202,8 @@
"rebuildDescription": "Reinstala o node_modules e reinicia", "rebuildDescription": "Reinstala o node_modules e reinicia",
"clearCache": "Limpar Cache", "clearCache": "Limpar Cache",
"clearCacheDescription": "Limpa cookies, armazenamento local e outros caches do app", "clearCacheDescription": "Limpa cookies, armazenamento local e outros caches do app",
"recreateSandbox": "Recriar Sandbox",
"recreateSandboxDescription": "Destrói o sandbox atual e cria um novo",
"loadingFiles": "Carregando arquivos...", "loadingFiles": "Carregando arquivos...",
"noAppSelected": "Nenhum app selecionado", "noAppSelected": "Nenhum app selecionado",
"refreshFiles": "Atualizar Arquivos", "refreshFiles": "Atualizar Arquivos",
......
...@@ -21,6 +21,9 @@ ...@@ -21,6 +21,9 @@
"selectReleaseChannel": "Selecionar canal de lançamento", "selectReleaseChannel": "Selecionar canal de lançamento",
"runtimeMode": "Modo de Execução", "runtimeMode": "Modo de Execução",
"runtimeModeDescription": "Selecione o runtime a ser usado para executar o app.", "runtimeModeDescription": "Selecione o runtime a ser usado para executar o app.",
"runtimeModeSwitchTitle": "Trocar o modo de execução?",
"runtimeModeSwitchDescription": "Trocar o modo de execução irá parar o app que está em execução no momento.",
"runtimeModeSwitchAction": "Trocar runtime",
"selectRuntimeMode": "Selecionar modo de execução", "selectRuntimeMode": "Selecionar modo de execução",
"nodePath": "Configuração do Caminho do Node.js", "nodePath": "Configuração do Caminho do Node.js",
"browseForNode": "Procurar Node.js", "browseForNode": "Procurar Node.js",
......
...@@ -202,6 +202,8 @@ ...@@ -202,6 +202,8 @@
"rebuildDescription": "重新安装 node_modules 并重启", "rebuildDescription": "重新安装 node_modules 并重启",
"clearCache": "清除缓存", "clearCache": "清除缓存",
"clearCacheDescription": "清除 Cookie、本地存储及其他应用缓存", "clearCacheDescription": "清除 Cookie、本地存储及其他应用缓存",
"recreateSandbox": "重新创建沙箱",
"recreateSandboxDescription": "销毁当前沙箱并创建一个新的沙箱",
"loadingFiles": "正在加载文件...", "loadingFiles": "正在加载文件...",
"noAppSelected": "未选择应用", "noAppSelected": "未选择应用",
"refreshFiles": "刷新文件", "refreshFiles": "刷新文件",
......
...@@ -21,6 +21,9 @@ ...@@ -21,6 +21,9 @@
"selectReleaseChannel": "选择发布通道", "selectReleaseChannel": "选择发布通道",
"runtimeMode": "运行时模式", "runtimeMode": "运行时模式",
"runtimeModeDescription": "选择用于运行应用的运行时。", "runtimeModeDescription": "选择用于运行应用的运行时。",
"runtimeModeSwitchTitle": "切换运行时模式?",
"runtimeModeSwitchDescription": "切换运行时模式会停止当前正在运行的应用。",
"runtimeModeSwitchAction": "切换运行时",
"selectRuntimeMode": "选择运行时模式", "selectRuntimeMode": "选择运行时模式",
"nodePath": "Node.js 路径配置", "nodePath": "Node.js 路径配置",
"browseForNode": "浏览 Node.js", "browseForNode": "浏览 Node.js",
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
parseEnvFile, parseEnvFile,
serializeEnvFile, serializeEnvFile,
} from "../utils/app_env_var_utils"; } from "../utils/app_env_var_utils";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
import { createTypedHandler } from "./base"; import { createTypedHandler } from "./base";
import { miscContracts } from "../types/misc"; import { miscContracts } from "../types/misc";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
...@@ -72,6 +73,10 @@ export function registerAppEnvVarsHandlers() { ...@@ -72,6 +73,10 @@ export function registerAppEnvVarsHandlers() {
// Write to .env.local file // Write to .env.local file
await fs.promises.writeFile(envFilePath, content, "utf8"); await fs.promises.writeFile(envFilePath, content, "utf8");
queueCloudSandboxSnapshotSync({
appId,
changedPaths: [ENV_FILE_NAME],
});
} catch (error) { } catch (error) {
console.error("Error setting app environment variables:", error); console.error("Error setting app environment variables:", error);
throw new Error( throw new Error(
......
...@@ -48,6 +48,21 @@ import { ...@@ -48,6 +48,21 @@ import {
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server"; import { startProxy } from "../utils/start_proxy_server";
import {
buildCloudSandboxFileMap,
CloudSandboxApiError,
createCloudSandboxShareLink,
createCloudSandbox,
destroyCloudSandbox,
getCloudSandboxStatus,
queueCloudSandboxSnapshotSync,
reconcileCloudSandboxes,
registerRunningCloudSandbox,
restartCloudSandbox,
setCloudSandboxSyncUpdateListener,
streamCloudSandboxLogs,
uploadCloudSandboxFiles,
} from "../utils/cloud_sandbox_provider";
import { createFromTemplate } from "./createFromTemplate"; import { createFromTemplate } from "./createFromTemplate";
import { import {
gitCommit, gitCommit,
...@@ -67,7 +82,7 @@ import { ...@@ -67,7 +82,7 @@ import {
} from "@/supabase_admin/supabase_utils"; } from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { AppSearchResult } from "@/lib/schemas"; import type { AppSearchResult, RuntimeMode2 } from "@/lib/schemas";
import { getAppPort } from "../../../shared/ports"; import { getAppPort } from "../../../shared/ports";
import { import {
...@@ -80,6 +95,37 @@ import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; ...@@ -80,6 +95,37 @@ import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
const logger = log.scope("app_handlers"); const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
function formatCloudSandboxError(error: unknown) {
if (!(error instanceof CloudSandboxApiError)) {
return error instanceof Error ? error.message : String(error);
}
switch (error.code) {
case "sandbox_pro_required":
return "Dyad Pro is required to use cloud sandboxes.";
case "sandbox_insufficient_credits":
return "You need at least 1 credit available to start a cloud sandbox.";
case "sandbox_billing_unavailable":
return "Dyad couldn’t verify sandbox billing right now. Please try again.";
case "sandbox_credits_exhausted":
return "This cloud sandbox stopped because your credits ran out.";
default:
if (error.status === 404) {
return "This cloud sandbox is no longer available.";
}
if (error.status === 401 || error.status === 403) {
return "Dyad couldn’t authorize the cloud sandbox request. Please try again.";
}
if (error.status === 429) {
return "Dyad is rate limiting cloud sandbox requests right now. Please try again.";
}
if (typeof error.status === "number" && error.status >= 500) {
return "Dyad’s cloud sandbox service is temporarily unavailable. Please try again.";
}
return error.message;
}
}
function sanitizeSnippetText(text: string) { function sanitizeSnippetText(text: string) {
return text.replace(/\s+/g, " ").trim(); return text.replace(/\s+/g, " ").trim();
} }
...@@ -191,6 +237,14 @@ async function executeApp({ ...@@ -191,6 +237,14 @@ async function executeApp({
installCommand, installCommand,
startCommand, startCommand,
}); });
} else if (runtimeMode === "cloud") {
await executeAppInCloud({
appPath,
appId,
event,
installCommand,
startCommand,
});
} else { } else {
await executeAppLocalNode({ await executeAppLocalNode({
appPath, appPath,
...@@ -203,6 +257,100 @@ async function executeApp({ ...@@ -203,6 +257,100 @@ async function executeApp({
} }
} }
function emitProxyServerStarted({
appId,
event,
proxyUrl,
originalUrl,
mode,
}: {
appId: number;
event: Electron.IpcMainInvokeEvent;
proxyUrl: string;
originalUrl: string;
mode: RuntimeMode2;
}) {
safeSend(event.sender, "app:output", {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}] mode=[${mode}]`,
appId,
});
}
async function ensureProxyForRunningApp({
appId,
event,
originalUrl,
mode,
}: {
appId: number;
event: Electron.IpcMainInvokeEvent;
originalUrl: string;
mode: RuntimeMode2;
}): Promise<void> {
const appInfo = runningApps.get(appId);
if (!appInfo) {
return;
}
const proxyAuthToken =
mode === "cloud" ? appInfo.cloudPreviewAuthToken : undefined;
if (
appInfo.proxyWorker &&
appInfo.originalUrl === originalUrl &&
appInfo.proxyAuthToken === proxyAuthToken &&
appInfo.proxyUrl
) {
emitProxyServerStarted({
appId,
event,
proxyUrl: appInfo.proxyUrl,
originalUrl,
mode,
});
return;
}
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
const proxyWorker = await startProxy(originalUrl, {
onStarted: (proxyUrl) => {
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyUrl = proxyUrl;
latestAppInfo.originalUrl = originalUrl;
latestAppInfo.proxyAuthToken = proxyAuthToken;
}
emitProxyServerStarted({
appId,
event,
proxyUrl,
originalUrl,
mode,
});
},
fixedHeaders:
mode === "cloud" && proxyAuthToken
? {
Authorization: `Bearer ${proxyAuthToken}`,
}
: undefined,
});
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyWorker = proxyWorker;
latestAppInfo.originalUrl = originalUrl;
latestAppInfo.proxyAuthToken = proxyAuthToken;
} else {
await proxyWorker.terminate();
}
}
async function executeAppLocalNode({ async function executeAppLocalNode({
appPath, appPath,
appId, appId,
...@@ -275,7 +423,8 @@ Details: ${details || "n/a"} ...@@ -275,7 +423,8 @@ Details: ${details || "n/a"}
runningApps.set(appId, { runningApps.set(appId, {
process: spawnedProcess, process: spawnedProcess,
processId: currentProcessId, processId: currentProcessId,
isDocker: false, mode: "host",
rendererSender: event.sender,
lastViewedAt: Date.now(), lastViewedAt: Date.now(),
}); });
...@@ -324,6 +473,73 @@ function flushAllAppOutputs(): void { ...@@ -324,6 +473,73 @@ function flushAllAppOutputs(): void {
pendingOutputs.clear(); pendingOutputs.clear();
} }
let cloudSandboxSyncUpdateListenerRegistered = false;
function registerCloudSandboxSyncUpdateListener(): void {
if (cloudSandboxSyncUpdateListenerRegistered) {
return;
}
setCloudSandboxSyncUpdateListener(({ appId, errorMessage }) => {
const appInfo = runningApps.get(appId);
if (!appInfo || appInfo.mode !== "cloud") {
return;
}
const previousErrorMessage = appInfo.cloudSyncErrorMessage ?? null;
appInfo.cloudSyncErrorMessage = errorMessage ?? undefined;
const sender = appInfo.rendererSender;
if (!sender) {
return;
}
if (errorMessage) {
if (previousErrorMessage === errorMessage) {
return;
}
addLog({
level: "error",
type: "server",
message: errorMessage,
timestamp: Date.now(),
appId,
});
safeSend(sender, "app:output", {
type: "sync-error",
message: errorMessage,
appId,
});
return;
}
if (!previousErrorMessage) {
return;
}
const recoveredMessage =
"Cloud sandbox sync recovered. Local changes are uploading again.";
addLog({
level: "info",
type: "server",
message: recoveredMessage,
timestamp: Date.now(),
appId,
});
safeSend(sender, "app:output", {
type: "sync-recovered",
message: recoveredMessage,
appId,
});
});
cloudSandboxSyncUpdateListenerRegistered = true;
}
function listenToProcess({ function listenToProcess({
process: spawnedProcess, process: spawnedProcess,
appId, appId,
...@@ -385,53 +601,12 @@ function listenToProcess({ ...@@ -385,53 +601,12 @@ function listenToProcess({
const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/); const urlMatch = message.match(/(https?:\/\/localhost:\d+\/?)/);
if (urlMatch) { if (urlMatch) {
const originalUrl = urlMatch[1]; const originalUrl = urlMatch[1];
const appInfo = runningApps.get(appId); await ensureProxyForRunningApp({
if (!appInfo) {
return;
}
// Reuse the existing proxy worker for this app if it already targets this URL.
if (
appInfo.proxyWorker &&
appInfo.originalUrl === originalUrl &&
appInfo.proxyUrl
) {
enqueueAppOutput(event.sender, {
type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${originalUrl}]`,
appId,
});
return;
}
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
const proxyWorker = await startProxy(originalUrl, {
onStarted: (proxyUrl) => {
// Store proxy URL in running app info for re-emission on app switch
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyUrl = proxyUrl;
latestAppInfo.originalUrl = originalUrl;
}
enqueueAppOutput(event.sender, {
type: "stdout",
message: `[dyad-proxy-server]started=[${proxyUrl}] original=[${originalUrl}]`,
appId, appId,
event,
originalUrl,
mode: "host",
}); });
},
});
const latestAppInfo = runningApps.get(appId);
if (latestAppInfo) {
latestAppInfo.proxyWorker = proxyWorker;
latestAppInfo.originalUrl = originalUrl;
} else {
await proxyWorker.terminate();
}
} }
} }
}); });
...@@ -661,7 +836,8 @@ ${errorOutput || "(empty)"}`, ...@@ -661,7 +836,8 @@ ${errorOutput || "(empty)"}`,
runningApps.set(appId, { runningApps.set(appId, {
process, process,
processId: currentProcessId, processId: currentProcessId,
isDocker: true, mode: "docker",
rendererSender: event.sender,
containerName, containerName,
lastViewedAt: Date.now(), lastViewedAt: Date.now(),
}); });
...@@ -674,6 +850,157 @@ ${errorOutput || "(empty)"}`, ...@@ -674,6 +850,157 @@ ${errorOutput || "(empty)"}`,
}); });
} }
async function executeAppInCloud({
appPath,
appId,
event,
installCommand,
startCommand,
}: {
appPath: string;
appId: number;
event: Electron.IpcMainInvokeEvent;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<void> {
const currentProcessId = processCounter.increment();
let sandboxId: string | undefined;
let previewUrl: string | undefined;
let previewAuthToken: string | undefined;
try {
const createResult = await createCloudSandbox({
appId,
appPath,
installCommand,
startCommand,
});
sandboxId = createResult.sandboxId;
previewUrl = createResult.previewUrl;
previewAuthToken = createResult.previewAuthToken;
const files = await buildCloudSandboxFileMap(appPath);
const uploadResult = await uploadCloudSandboxFiles({
sandboxId,
files,
replaceAll: true,
});
previewUrl = uploadResult.previewUrl ?? previewUrl;
previewAuthToken = uploadResult.previewAuthToken ?? previewAuthToken;
} catch (error) {
if (sandboxId) {
try {
await destroyCloudSandbox(sandboxId);
} catch (cleanupError) {
logger.warn(
`Failed to clean up cloud sandbox ${sandboxId} after startup error for app ${appId}:`,
cleanupError,
);
}
}
throw new Error(formatCloudSandboxError(error));
}
const resolvedPreviewUrl = previewUrl;
const resolvedPreviewAuthToken = previewAuthToken;
if (!sandboxId || !resolvedPreviewUrl || !resolvedPreviewAuthToken) {
throw new Error(
"Cloud sandbox startup returned incomplete preview credentials.",
);
}
const cloudLogAbortController = new AbortController();
runningApps.set(appId, {
process: null,
processId: currentProcessId,
mode: "cloud",
rendererSender: event.sender,
cloudSandboxId: sandboxId,
cloudPreviewUrl: resolvedPreviewUrl,
cloudPreviewAuthToken: resolvedPreviewAuthToken,
cloudLogAbortController,
lastViewedAt: Date.now(),
originalUrl: resolvedPreviewUrl,
});
registerRunningCloudSandbox({
appId,
appPath,
sandboxId,
});
await ensureProxyForRunningApp({
appId,
event,
originalUrl: resolvedPreviewUrl,
mode: "cloud",
});
startCloudSandboxLogStream({
appId,
event,
sandboxId,
cloudLogAbortController,
});
}
function startCloudSandboxLogStream(input: {
appId: number;
event: Electron.IpcMainInvokeEvent;
sandboxId: string;
cloudLogAbortController: AbortController;
}) {
void (async () => {
try {
for await (const message of streamCloudSandboxLogs(
input.sandboxId,
input.cloudLogAbortController.signal,
)) {
const appInfo = runningApps.get(input.appId);
if (!appInfo || appInfo.cloudSandboxId !== input.sandboxId) {
return;
}
addLog({
level: "info",
type: "server",
message,
timestamp: Date.now(),
appId: input.appId,
});
safeSend(input.event.sender, "app:output", {
type: "stdout",
message,
appId: input.appId,
});
}
} catch (error) {
if (input.cloudLogAbortController.signal.aborted) {
return;
}
const message =
error instanceof Error
? error.message
: `Cloud sandbox log stream failed: ${String(error)}`;
addLog({
level: "error",
type: "server",
message,
timestamp: Date.now(),
appId: input.appId,
});
safeSend(input.event.sender, "app:output", {
type: "stderr",
message,
appId: input.appId,
});
}
})();
}
// Helper to kill process on a specific port (cross-platform, using kill-port) // Helper to kill process on a specific port (cross-platform, using kill-port)
async function killProcessOnPort(port: number): Promise<void> { async function killProcessOnPort(port: number): Promise<void> {
try { try {
...@@ -844,6 +1171,8 @@ async function searchAppFilesWithRipgrep({ ...@@ -844,6 +1171,8 @@ async function searchAppFilesWithRipgrep({
} }
export function registerAppHandlers() { export function registerAppHandlers() {
registerCloudSandboxSyncUpdateListener();
createTypedHandler(systemContracts.restartDyad, async () => { createTypedHandler(systemContracts.restartDyad, async () => {
app.relaunch(); app.relaunch();
app.quit(); app.quit();
...@@ -1119,10 +1448,12 @@ export function registerAppHandlers() { ...@@ -1119,10 +1448,12 @@ export function registerAppHandlers() {
// Re-emit the proxy URL so the frontend can restore the preview // Re-emit the proxy URL so the frontend can restore the preview
const appInfo = runningApps.get(appId); const appInfo = runningApps.get(appId);
if (appInfo?.proxyUrl && appInfo?.originalUrl) { if (appInfo?.proxyUrl && appInfo?.originalUrl) {
safeSend(event.sender, "app:output", { emitProxyServerStarted({
type: "stdout",
message: `[dyad-proxy-server]started=[${appInfo.proxyUrl}] original=[${appInfo.originalUrl}]`,
appId, appId,
event,
proxyUrl: appInfo.proxyUrl,
originalUrl: appInfo.originalUrl,
mode: appInfo.mode,
}); });
} }
return; return;
...@@ -1186,11 +1517,14 @@ export function registerAppHandlers() { ...@@ -1186,11 +1517,14 @@ export function registerAppHandlers() {
const { process, processId } = appInfo; const { process, processId } = appInfo;
logger.log( logger.log(
`Found running app ${appId} with processId ${processId} (PID: ${process.pid}). Attempting to stop.`, `Found running app ${appId} with processId ${processId}${process?.pid ? ` (PID: ${process.pid})` : ""}. Attempting to stop.`,
); );
// Check if the process is already exited or closed // Check if the process is already exited or closed
if (process.exitCode !== null || process.signalCode !== null) { if (
process &&
(process.exitCode !== null || process.signalCode !== null)
) {
logger.log( logger.log(
`Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.`, `Process for app ${appId} (PID: ${process.pid}) already exited (code: ${process.exitCode}, signal: ${process.signalCode}). Cleaning up map.`,
); );
...@@ -1202,16 +1536,22 @@ export function registerAppHandlers() { ...@@ -1202,16 +1536,22 @@ export function registerAppHandlers() {
await stopAppByInfo(appId, appInfo); await stopAppByInfo(appId, appInfo);
// Now, safely remove the app from the map *after* confirming closure // Now, safely remove the app from the map *after* confirming closure
if (process) {
removeAppIfCurrentProcess(appId, process); removeAppIfCurrentProcess(appId, process);
}
return; return;
} catch (error: any) { } catch (error: any) {
logger.error( logger.error(
`Error stopping app ${appId} (PID: ${process.pid}, processId: ${processId}):`, `Error stopping app ${appId}${process?.pid ? ` (PID: ${process.pid}, processId: ${processId})` : ` (processId: ${processId})`}:`,
error, error,
); );
// Attempt cleanup even if an error occurred during the stop process // Attempt cleanup even if an error occurred during the stop process
if (process) {
removeAppIfCurrentProcess(appId, process); removeAppIfCurrentProcess(appId, process);
} else if (appInfo.mode !== "cloud") {
runningApps.delete(appId);
}
throw new DyadError( throw new DyadError(
`Failed to stop app ${appId}: ${error.message}`, `Failed to stop app ${appId}: ${error.message}`,
DyadErrorKind.External, DyadErrorKind.External,
...@@ -1220,13 +1560,133 @@ export function registerAppHandlers() { ...@@ -1220,13 +1560,133 @@ export function registerAppHandlers() {
}); });
}); });
createTypedHandler(
appContracts.getCloudSandboxStatus,
async (event, params) => {
const { appId } = params;
const appInfo = runningApps.get(appId);
if (!appInfo || appInfo.mode !== "cloud" || !appInfo.cloudSandboxId) {
return null;
}
try {
const status = await getCloudSandboxStatus(appInfo.cloudSandboxId);
const previewChanged =
appInfo.cloudPreviewUrl !== status.previewUrl ||
appInfo.cloudPreviewAuthToken !== status.previewAuthToken;
appInfo.cloudPreviewUrl = status.previewUrl;
appInfo.cloudPreviewAuthToken = status.previewAuthToken;
if (previewChanged && appInfo.proxyWorker) {
await ensureProxyForRunningApp({
appId,
event,
originalUrl: status.previewUrl,
mode: "cloud",
});
} else {
appInfo.originalUrl = status.previewUrl;
}
return {
...status,
localSyncErrorMessage: appInfo.cloudSyncErrorMessage ?? null,
};
} catch (error) {
logger.error(
`Failed to fetch cloud sandbox status for app ${appId}:`,
error,
);
throw new DyadError(
formatCloudSandboxError(error),
DyadErrorKind.External,
);
}
},
);
createTypedHandler(
appContracts.createCloudSandboxShareLink,
async (_, params) => {
const { appId, expiresInSeconds } = params;
const appInfo = runningApps.get(appId);
if (!appInfo || appInfo.mode !== "cloud" || !appInfo.cloudSandboxId) {
throw new DyadError(
`App ${appId} is not running in cloud mode`,
DyadErrorKind.External,
);
}
try {
return await createCloudSandboxShareLink(appInfo.cloudSandboxId, {
expiresInSeconds,
});
} catch (error) {
logger.error(
`Failed to create cloud sandbox share link for app ${appId}:`,
error,
);
throw new DyadError(
formatCloudSandboxError(error),
DyadErrorKind.External,
);
}
},
);
createTypedHandler(appContracts.restartApp, async (event, params) => { createTypedHandler(appContracts.restartApp, async (event, params) => {
const { appId, removeNodeModules } = params; const { appId, removeNodeModules, recreateSandbox } = params;
logger.log(`Restarting app ${appId}`); logger.log(`Restarting app ${appId}`);
return withLock(appId, async () => { return withLock(appId, async () => {
try { try {
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
// First stop the app if it's running // First stop the app if it's running
const appInfo = runningApps.get(appId); const appInfo = runningApps.get(appId);
if (
appInfo &&
appInfo.mode === "cloud" &&
appInfo.cloudSandboxId &&
!recreateSandbox
) {
logger.log(`Restarting cloud sandbox app ${appId} in place`);
const restartResult = await restartCloudSandbox(
appInfo.cloudSandboxId,
);
appInfo.cloudPreviewUrl = restartResult.previewUrl;
appInfo.cloudPreviewAuthToken = restartResult.previewAuthToken;
appInfo.lastViewedAt = Date.now();
appInfo.cloudLogAbortController?.abort();
appInfo.cloudLogAbortController = new AbortController();
await ensureProxyForRunningApp({
appId,
event,
originalUrl: restartResult.previewUrl,
mode: "cloud",
});
startCloudSandboxLogStream({
appId,
event,
sandboxId: appInfo.cloudSandboxId,
cloudLogAbortController: appInfo.cloudLogAbortController,
});
return;
}
if (appInfo) { if (appInfo) {
const { processId } = appInfo; const { processId } = appInfo;
logger.log( logger.log(
...@@ -1240,17 +1700,6 @@ export function registerAppHandlers() { ...@@ -1240,17 +1700,6 @@ export function registerAppHandlers() {
// There may have been a previous run that left a process on this port. // There may have been a previous run that left a process on this port.
await cleanUpPort(getAppPort(appId)); await cleanUpPort(getAppPort(appId));
// Now start the app again
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!app) {
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(app.path);
// Remove node_modules if requested // Remove node_modules if requested
if (removeNodeModules) { if (removeNodeModules) {
const settings = readSettings(); const settings = readSettings();
...@@ -1305,6 +1754,7 @@ export function registerAppHandlers() { ...@@ -1305,6 +1754,7 @@ export function registerAppHandlers() {
return; return;
} catch (error) { } catch (error) {
logger.error(`Error restarting app ${appId}:`, error); logger.error(`Error restarting app ${appId}:`, error);
console.error(error);
throw error; throw error;
} }
}); });
...@@ -1368,6 +1818,11 @@ export function registerAppHandlers() { ...@@ -1368,6 +1818,11 @@ export function registerAppHandlers() {
); );
} }
queueCloudSandboxSnapshotSync({
appId,
changedPaths: [filePath],
});
if (app.supabaseProjectId) { if (app.supabaseProjectId) {
// Check if shared module was modified - redeploy all functions // Check if shared module was modified - redeploy all functions
if (isSharedServerModule(filePath)) { if (isSharedServerModule(filePath)) {
...@@ -1414,6 +1869,7 @@ export function registerAppHandlers() { ...@@ -1414,6 +1869,7 @@ export function registerAppHandlers() {
} }
} }
} }
return {}; return {};
}); });
...@@ -1856,6 +2312,11 @@ export function registerAppHandlers() { ...@@ -1856,6 +2312,11 @@ export function registerAppHandlers() {
} }
const { process } = appInfo; const { process } = appInfo;
if (!process) {
throw new Error(
`App ${appId} is running in ${appInfo.mode} mode and does not accept stdin responses.`,
);
}
if (!process.stdin) { if (!process.stdin) {
throw new DyadError( throw new DyadError(
...@@ -2215,6 +2676,10 @@ export function registerAppHandlers() { ...@@ -2215,6 +2676,10 @@ export function registerAppHandlers() {
} }
}); });
void reconcileCloudSandboxes().catch((error) => {
logger.warn("Failed to reconcile cloud sandboxes on startup:", error);
});
// Start the garbage collection for idle apps // Start the garbage collection for idle apps
startAppGarbageCollection(); startAppGarbageCollection();
} }
......
...@@ -33,9 +33,21 @@ import { ...@@ -33,9 +33,21 @@ import {
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { retryOnLocked } from "../utils/retryOnLocked"; import { retryOnLocked } from "../utils/retryOnLocked";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { syncCloudSandboxSnapshot } from "../utils/cloud_sandbox_provider";
const logger = log.scope("version_handlers"); const logger = log.scope("version_handlers");
async function syncCloudSandboxSnapshotBestEffort(appId: number) {
try {
await syncCloudSandboxSnapshot({ appId });
} catch (error) {
logger.warn(
`Cloud sandbox sync failed after version operation for app ${appId}:`,
error,
);
}
}
async function restoreBranchForPreview({ async function restoreBranchForPreview({
appId, appId,
dbTimestamp, dbTimestamp,
...@@ -365,6 +377,7 @@ export function registerVersionHandlers() { ...@@ -365,6 +377,7 @@ export function registerVersionHandlers() {
// Continue with the revert operation even if function deployment fails // Continue with the revert operation even if function deployment fails
} }
} }
await syncCloudSandboxSnapshotBestEffort(appId);
if (warningMessage) { if (warningMessage) {
return { warningMessage }; return { warningMessage };
} }
...@@ -449,6 +462,7 @@ export function registerVersionHandlers() { ...@@ -449,6 +462,7 @@ export function registerVersionHandlers() {
path: fullAppPath, path: fullAppPath,
ref: gitRef, ref: gitRef,
}); });
await syncCloudSandboxSnapshotBestEffort(appId);
}); });
}); });
} }
......
...@@ -5,16 +5,25 @@ import { ...@@ -5,16 +5,25 @@ import {
} from "@/ipc/utils/socket_firewall"; } from "@/ipc/utils/socket_firewall";
import { ExecuteAddDependencyError } from "./executeAddDependency"; import { ExecuteAddDependencyError } from "./executeAddDependency";
const { executeAddDependencyMock, readSettingsMock } = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
executeAddDependencyMock: vi.fn(), executeAddDependencyMock: vi.fn(),
queueCloudSandboxSnapshotSyncMock: vi.fn(),
readSettingsMock: vi.fn(), readSettingsMock: vi.fn(),
})); }));
const {
executeAddDependencyMock,
queueCloudSandboxSnapshotSyncMock,
readSettingsMock,
} = mocks;
const dbUpdates: Array<Record<string, unknown>> = []; const dbUpdates: Array<Record<string, unknown>> = [];
vi.mock("node:fs", async () => ({ vi.mock("node:fs", async () => ({
default: { default: {
existsSync: vi.fn().mockReturnValue(false), existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
promises: { promises: {
readFile: vi.fn().mockResolvedValue(""), readFile: vi.fn().mockResolvedValue(""),
}, },
...@@ -56,7 +65,11 @@ vi.mock("../utils/git_utils", () => ({ ...@@ -56,7 +65,11 @@ vi.mock("../utils/git_utils", () => ({
})); }));
vi.mock("@/main/settings", () => ({ vi.mock("@/main/settings", () => ({
readSettings: readSettingsMock, readSettings: mocks.readSettingsMock,
}));
vi.mock("../utils/cloud_sandbox_provider", () => ({
queueCloudSandboxSnapshotSync: mocks.queueCloudSandboxSnapshotSyncMock,
})); }));
vi.mock("./executeAddDependency", async () => { vi.mock("./executeAddDependency", async () => {
...@@ -66,12 +79,12 @@ vi.mock("./executeAddDependency", async () => { ...@@ -66,12 +79,12 @@ vi.mock("./executeAddDependency", async () => {
return { return {
...actual, ...actual,
executeAddDependency: executeAddDependencyMock, executeAddDependency: mocks.executeAddDependencyMock,
}; };
}); });
import { db } from "../../db"; import { db } from "../../db";
import { gitAdd } from "../utils/git_utils"; import { gitAdd, hasStagedChanges } from "../utils/git_utils";
import { processFullResponseActions } from "./response_processor"; import { processFullResponseActions } from "./response_processor";
describe("processFullResponseActions add dependency errors", () => { describe("processFullResponseActions add dependency errors", () => {
...@@ -156,4 +169,29 @@ describe("processFullResponseActions add dependency errors", () => { ...@@ -156,4 +169,29 @@ describe("processFullResponseActions add dependency errors", () => {
warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE], warningMessages: [SOCKET_FIREWALL_WARNING_MESSAGE],
}); });
}); });
it("queues delete tags for cloud sync even when the local path is already missing", async () => {
vi.mocked(hasStagedChanges).mockResolvedValueOnce(true);
const result = await processFullResponseActions(
`
<dyad-write path="src/file1.js">console.log("Hello");</dyad-write>
<dyad-delete path="src/missing.js"></dyad-delete>
`,
1,
{
chatSummary: undefined,
messageId: 1,
},
);
expect(result).toMatchObject({
updatedFiles: true,
});
expect(queueCloudSandboxSnapshotSyncMock).toHaveBeenCalledWith({
appId: 1,
changedPaths: ["src/file1.js"],
deletedPaths: ["src/missing.js"],
});
});
}); });
...@@ -47,6 +47,7 @@ import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace ...@@ -47,6 +47,7 @@ import { applySearchReplace } from "../../pro/main/ipc/processors/search_replace
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { executeCopyFile } from "../utils/copy_file_utils"; import { executeCopyFile } from "../utils/copy_file_utils";
import { escapeXmlAttr, escapeXmlContent } from "../../../shared/xmlEscape"; import { escapeXmlAttr, escapeXmlContent } from "../../../shared/xmlEscape";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const logger = log.scope("response_processor"); const logger = log.scope("response_processor");
...@@ -647,6 +648,17 @@ export async function processFullResponseActions( ...@@ -647,6 +648,17 @@ export async function processFullResponseActions(
}) })
.where(eq(messages.id, messageId)); .where(eq(messages.id, messageId));
if (hasChanges) {
queueCloudSandboxSnapshotSync({
appId: chatWithApp.app.id,
changedPaths: [...writtenFiles, ...renamedFiles],
deletedPaths: [
...dyadDeletePaths,
...dyadRenameTags.map((renameTag) => renameTag.from),
],
});
}
return { return {
updatedFiles: hasChanges, updatedFiles: hasChanges,
extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined, extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined,
......
...@@ -112,6 +112,58 @@ export const AppIdParamsSchema = z.object({ ...@@ -112,6 +112,58 @@ export const AppIdParamsSchema = z.object({
export const RestartAppParamsSchema = z.object({ export const RestartAppParamsSchema = z.object({
appId: z.number(), appId: z.number(),
removeNodeModules: z.boolean().optional(), removeNodeModules: z.boolean().optional(),
recreateSandbox: z.boolean().optional(),
});
export const CloudSandboxStatusSchema = z.object({
sandboxId: z.string(),
status: z.string(),
previewUrl: z.string(),
previewAuthToken: z.string(),
previewPort: z.number().int(),
syncRevision: z.number().int().nonnegative(),
initialSyncCompleted: z.boolean(),
appStatus: z.enum(["starting", "running", "standby", "failed"]),
syncAgentHealthy: z.boolean(),
createdAt: z.string(),
lastActiveAt: z.string(),
lastSuccessfulSyncAt: z.string().nullable(),
expiresAt: z.string(),
billingState: z.enum([
"active",
"charging",
"terminated",
"billing_unavailable",
]),
billingStartedAt: z.string(),
billingLockedAt: z.string().nullable(),
lastChargedAt: z.string().nullable(),
nextChargeAt: z.string(),
billingSlicesCharged: z.number().int().nonnegative(),
creditsCharged: z.number().nonnegative(),
terminationReason: z
.enum([
"manual",
"idle_timeout",
"credits_exhausted",
"billing_unavailable",
])
.nullable(),
lastErrorCode: z.string().nullable(),
lastErrorMessage: z.string().nullable(),
localSyncErrorMessage: z.string().nullable().optional(),
});
export const CreateCloudSandboxShareLinkParamsSchema = z.object({
appId: z.number(),
expiresInSeconds: z.number().int().positive().optional(),
});
export const CreateCloudSandboxShareLinkResultSchema = z.object({
sandboxId: z.string(),
shareLinkId: z.string(),
url: z.string(),
expiresAt: z.string(),
}); });
/** /**
...@@ -316,6 +368,18 @@ export const appContracts = { ...@@ -316,6 +368,18 @@ export const appContracts = {
output: z.void(), output: z.void(),
}), }),
getCloudSandboxStatus: defineContract({
channel: "get-cloud-sandbox-status",
input: AppIdParamsSchema,
output: CloudSandboxStatusSchema.nullable(),
}),
createCloudSandboxShareLink: defineContract({
channel: "create-cloud-sandbox-share-link",
input: CreateCloudSandboxShareLinkParamsSchema,
output: CreateCloudSandboxShareLinkResultSchema,
}),
editAppFile: defineContract({ editAppFile: defineContract({
channel: "edit-app-file", channel: "edit-app-file",
input: EditAppFileParamsSchema, input: EditAppFileParamsSchema,
...@@ -431,3 +495,4 @@ export type AppSearchResult = z.infer<typeof AppSearchResultSchema>; ...@@ -431,3 +495,4 @@ export type AppSearchResult = z.infer<typeof AppSearchResultSchema>;
export type UpdateAppCommandsParams = z.infer< export type UpdateAppCommandsParams = z.infer<
typeof UpdateAppCommandsParamsSchema typeof UpdateAppCommandsParamsSchema
>; >;
export type CloudSandboxStatus = z.infer<typeof CloudSandboxStatusSchema>;
...@@ -295,7 +295,15 @@ export type { DeepLinkData } from "../deep_link_data"; ...@@ -295,7 +295,15 @@ export type { DeepLinkData } from "../deep_link_data";
// ============================================================================= // =============================================================================
export const AppOutputSchema = z.object({ export const AppOutputSchema = z.object({
type: z.enum(["stdout", "stderr", "input-requested", "client-error", "info"]), type: z.enum([
"stdout",
"stderr",
"input-requested",
"client-error",
"info",
"sync-error",
"sync-recovered",
]),
message: z.string(), message: z.string(),
appId: z.number(), appId: z.number(),
timestamp: z.number().optional(), timestamp: z.number().optional(),
......
...@@ -9,6 +9,7 @@ import path from "path"; ...@@ -9,6 +9,7 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import log from "electron-log"; import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "./cloud_sandbox_provider";
const logger = log.scope("app_env_var_utils"); const logger = log.scope("app_env_var_utils");
...@@ -41,6 +42,10 @@ export async function updatePostgresUrlEnvVar({ ...@@ -41,6 +42,10 @@ export async function updatePostgresUrlEnvVar({
const envFileContents = serializeEnvFile(envVars); const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents); await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
queueCloudSandboxSnapshotSync({
appPath: getDyadAppPath(appPath),
changedPaths: [ENV_FILE_NAME],
});
} }
export async function updateDbPushEnvVar({ export async function updateDbPushEnvVar({
...@@ -76,6 +81,10 @@ export async function updateDbPushEnvVar({ ...@@ -76,6 +81,10 @@ export async function updateDbPushEnvVar({
const envFileContents = serializeEnvFile(envVars); const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents); await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
queueCloudSandboxSnapshotSync({
appPath: getDyadAppPath(appPath),
changedPaths: [ENV_FILE_NAME],
});
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to update DB push environment variable for app ${appPath}: ${error}`, `Failed to update DB push environment variable for app ${appPath}: ${error}`,
......
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const { gitIsIgnoredIsoMock } = vi.hoisted(() => ({
gitIsIgnoredIsoMock: vi.fn(),
}));
vi.mock("@/main/settings", () => ({
readSettings: () => ({
providerSettings: {
auto: {
apiKey: {
value: "test-key",
},
},
},
}),
}));
vi.mock("./test_utils", () => ({
IS_TEST_BUILD: true,
}));
vi.mock("./git_utils", () => ({
gitIsIgnoredIso: gitIsIgnoredIsoMock,
}));
import {
CloudSandboxApiError,
createCloudSandbox,
buildCloudSandboxFileMap,
queueCloudSandboxSnapshotSync,
reconcileCloudSandboxes,
registerRunningCloudSandbox,
setCloudSandboxSyncUpdateListener,
syncCloudSandboxDirtyPaths,
stopCloudSandboxFileSync,
syncCloudSandboxSnapshot,
unregisterRunningCloudSandbox,
uploadCloudSandboxFiles,
} from "./cloud_sandbox_provider";
describe("cloud_sandbox_provider incremental sync", () => {
let appPath: string;
let fetchMock: ReturnType<typeof vi.fn>;
let fetchSpy: { mockRestore: () => void };
beforeEach(async () => {
vi.useFakeTimers();
gitIsIgnoredIsoMock.mockReset();
gitIsIgnoredIsoMock.mockResolvedValue(false);
appPath = await fs.mkdtemp(path.join(os.tmpdir(), "dyad-cloud-sync-"));
fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
previewUrl: "https://preview.example.test",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
});
fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock);
registerRunningCloudSandbox({
appId: 1,
appPath,
sandboxId: "sandbox-1",
});
});
afterEach(async () => {
setCloudSandboxSyncUpdateListener(undefined);
stopCloudSandboxFileSync(1);
unregisterRunningCloudSandbox({ appId: 1, appPath });
fetchSpy.mockRestore();
vi.useRealTimers();
await fs.rm(appPath, { recursive: true, force: true });
});
it("uploads only dirty changed files for incremental syncs", async () => {
await fs.writeFile(path.join(appPath, "src.ts"), "console.log('hi');");
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["src.ts"],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({
files: {
"src.ts": "console.log('hi');",
},
replaceAll: false,
deletedFiles: [],
});
});
it("uploads changed and deleted paths together", async () => {
await fs.writeFile(path.join(appPath, "keep.ts"), "updated");
await fs.writeFile(path.join(appPath, "old.ts"), "obsolete");
await fs.unlink(path.join(appPath, "old.ts"));
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["keep.ts"],
deletedPaths: ["old.ts"],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
files: {
"keep.ts": "updated",
},
replaceAll: false,
deletedFiles: ["old.ts"],
});
});
it("keeps full snapshot sync available for reconcile paths", async () => {
await fs.writeFile(path.join(appPath, "a.ts"), "A");
await fs.mkdir(path.join(appPath, "nested"));
await fs.writeFile(path.join(appPath, "nested", "b.ts"), "B");
await syncCloudSandboxSnapshot({ appId: 1 });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
files: {
"a.ts": "A",
"nested/b.ts": "B",
},
replaceAll: true,
deletedFiles: [],
});
});
it("excludes gitignored paths, keeps root env files, and skips symlinks", async () => {
await fs.writeFile(
path.join(appPath, "visible.ts"),
"export const ok = true;",
);
await fs.writeFile(path.join(appPath, ".env"), "ROOT_ENV=1");
await fs.writeFile(path.join(appPath, ".env.local"), "ROOT_ENV_LOCAL=1");
await fs.writeFile(path.join(appPath, "ignored.ts"), "ignored");
await fs.mkdir(path.join(appPath, "ignored-dir"));
await fs.writeFile(
path.join(appPath, "ignored-dir", "secret.ts"),
"secret",
);
await fs.mkdir(path.join(appPath, "nested"));
await fs.writeFile(path.join(appPath, "nested", ".env.local"), "nested");
await fs.writeFile(path.join(appPath, "symlink-target.ts"), "outside");
await fs.symlink(
path.join(appPath, "symlink-target.ts"),
path.join(appPath, "linked.ts"),
);
gitIsIgnoredIsoMock.mockImplementation(async ({ filepath }) => {
return (
filepath === ".env" ||
filepath === ".env.local" ||
filepath === "ignored.ts" ||
filepath === "ignored-dir" ||
filepath === "nested/.env.local"
);
});
await expect(buildCloudSandboxFileMap(appPath)).resolves.toEqual({
".env": "ROOT_ENV=1",
".env.local": "ROOT_ENV_LOCAL=1",
"symlink-target.ts": "outside",
"visible.ts": "export const ok = true;",
});
});
it("treats ignored and symlinked changed paths as deletions during incremental sync", async () => {
await fs.writeFile(path.join(appPath, "changed.ts"), "updated");
await fs.writeFile(path.join(appPath, ".env.local"), "SAFE_ENV=1");
await fs.writeFile(path.join(appPath, "ignored.ts"), "ignored");
await fs.writeFile(path.join(appPath, "symlink-target.ts"), "target");
await fs.symlink(
path.join(appPath, "symlink-target.ts"),
path.join(appPath, "linked.ts"),
);
gitIsIgnoredIsoMock.mockImplementation(async ({ filepath }) => {
return filepath === ".env.local" || filepath === "ignored.ts";
});
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["changed.ts", ".env.local", "ignored.ts", "linked.ts"],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
files: {
".env.local": "SAFE_ENV=1",
"changed.ts": "updated",
},
replaceAll: false,
deletedFiles: ["ignored.ts", "linked.ts"],
});
});
it("promotes gitignore changes to a full snapshot sync", async () => {
await fs.writeFile(path.join(appPath, ".gitignore"), "dist\n");
await fs.writeFile(path.join(appPath, "index.ts"), "console.log('ok');");
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: [".gitignore"],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
files: {
".gitignore": "dist\n",
"index.ts": "console.log('ok');",
},
replaceAll: true,
deletedFiles: [],
});
});
it("notifies listeners when syncs fail and later recover", async () => {
const syncUpdateListener = vi.fn();
setCloudSandboxSyncUpdateListener(syncUpdateListener);
await fs.writeFile(path.join(appPath, "src.ts"), "console.log('hi');");
fetchMock.mockRejectedValueOnce(new Error("network down"));
await expect(
syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["src.ts"],
}),
).rejects.toThrow("network down");
expect(syncUpdateListener).toHaveBeenCalledWith({
appId: 1,
errorMessage: "Cloud sandbox sync failed: network down",
});
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["src.ts"],
});
expect(syncUpdateListener).toHaveBeenLastCalledWith({
appId: 1,
errorMessage: null,
});
});
it("does not drop queued changes that arrive while an upload is in flight", async () => {
vi.useRealTimers();
const makeUploadResponse = () =>
new Response(
JSON.stringify({
previewUrl: "https://preview.example.test",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
await fs.writeFile(path.join(appPath, "first.ts"), "first");
await fs.writeFile(path.join(appPath, "second.ts"), "second");
let resolveFirstUpload: ((response: Response) => void) | undefined;
fetchMock
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirstUpload = resolve;
}),
)
.mockImplementation(async () => makeUploadResponse());
queueCloudSandboxSnapshotSync({
appId: 1,
changedPaths: ["first.ts"],
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(fetchMock).toHaveBeenCalledTimes(1);
queueCloudSandboxSnapshotSync({
appId: 1,
changedPaths: ["second.ts"],
});
resolveFirstUpload?.(makeUploadResponse());
await new Promise((resolve) => setTimeout(resolve, 350));
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondInit] = fetchMock.mock.calls[1];
expect(JSON.parse(String(secondInit?.body))).toEqual({
files: {
"second.ts": "second",
},
replaceAll: false,
deletedFiles: [],
});
});
it("notifies listeners when queued sync uploads fail and later recover", async () => {
vi.useRealTimers();
const syncUpdateListener = vi.fn();
setCloudSandboxSyncUpdateListener(syncUpdateListener);
await fs.writeFile(path.join(appPath, "src.ts"), "console.log('hi');");
fetchMock.mockRejectedValueOnce(new Error("network down"));
queueCloudSandboxSnapshotSync({
appId: 1,
changedPaths: ["src.ts"],
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(syncUpdateListener).toHaveBeenCalledWith({
appId: 1,
errorMessage: "Cloud sandbox sync failed: network down",
});
queueCloudSandboxSnapshotSync({
appId: 1,
changedPaths: ["src.ts"],
});
await new Promise((resolve) => setTimeout(resolve, 350));
expect(syncUpdateListener).toHaveBeenLastCalledWith({
appId: 1,
errorMessage: null,
});
});
});
describe("cloud_sandbox_provider sandbox creation", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let fetchSpy: { mockRestore: () => void };
beforeEach(() => {
fetchMock = vi.fn(async () => {
return new Response(
JSON.stringify({
sandboxId: "sandbox-1",
previewUrl: "https://preview.example.test",
previewAuthToken: "preview-auth-token",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
});
fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock);
});
afterEach(() => {
fetchSpy.mockRestore();
});
it("uses default commands when custom commands are missing", async () => {
await createCloudSandbox({
appId: 42,
appPath: "/tmp/app",
installCommand: null,
startCommand: undefined,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
appId: 42,
appPath: "/tmp/app",
installCommand: "pnpm install",
startCommand: "pnpm run dev",
});
});
it("preserves explicit custom commands after trimming", async () => {
await createCloudSandbox({
appId: 42,
appPath: "/tmp/app",
installCommand: " npm ci ",
startCommand: " npm run dev -- --port 3000 ",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({
appId: 42,
appPath: "/tmp/app",
installCommand: "npm ci",
startCommand: "npm run dev -- --port 3000",
});
});
it("throws when the engine response is missing sandboxId", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
previewUrl: "https://preview.example.test",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await expect(
createCloudSandbox({
appId: 42,
appPath: "/tmp/app",
}),
).rejects.toThrow(
"Invalid create sandbox response from cloud sandbox API:",
);
});
});
describe("cloud_sandbox_provider response validation", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let fetchSpy: { mockRestore: () => void };
beforeEach(() => {
fetchMock = vi.fn();
fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock);
});
afterEach(() => {
fetchSpy.mockRestore();
});
it("throws when upload files response has an invalid previewUrl", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
previewUrl: 123,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await expect(
uploadCloudSandboxFiles({
sandboxId: "sandbox-1",
files: {},
}),
).rejects.toThrow(
"Invalid upload sandbox files response from cloud sandbox API:",
);
});
it("throws when reconcile response has invalid sandbox ids", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
reconciledSandboxIds: ["sandbox-1", ""],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await expect(reconcileCloudSandboxes()).rejects.toThrow(
"Invalid reconcile sandboxes response from cloud sandbox API:",
);
});
it("treats reconcile 404s as an unsupported endpoint and ignores them", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "Not Found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);
await expect(reconcileCloudSandboxes()).resolves.toEqual([]);
});
it("does not swallow non-404 reconcile failures", async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
message: "Upstream mentioned 404 while returning a 503",
}),
{
status: 503,
headers: { "Content-Type": "application/json" },
},
),
);
await expect(reconcileCloudSandboxes()).rejects.toThrow(
"Upstream mentioned 404 while returning a 503",
);
});
it("uses status-based messages instead of surfacing raw error bodies", async () => {
fetchMock.mockResolvedValueOnce(
new Response("<html>upstream exploded</html>", {
status: 503,
headers: { "Content-Type": "text/html" },
}),
);
const error = await uploadCloudSandboxFiles({
sandboxId: "sandbox-1",
files: {},
}).catch((caughtError) => caughtError);
expect(error).toBeInstanceOf(CloudSandboxApiError);
expect(error).toMatchObject({
message:
"Dyad’s cloud sandbox service is temporarily unavailable. Please try again.",
status: 503,
});
});
});
import { readSettings } from "@/main/settings";
import { normalizePath } from "../../../shared/normalizePath";
import { promises as fsPromises } from "node:fs";
import path from "node:path";
import log from "electron-log";
import { IS_TEST_BUILD } from "./test_utils";
import { z } from "zod";
import { gitIsIgnoredIso } from "./git_utils";
const logger = log.scope("cloud_sandbox_provider");
const DYAD_ENGINE_URL =
process.env.DYAD_ENGINE_URL ?? "https://engine.dyad.sh/v1";
const CLOUD_SANDBOX_EXCLUDED_DIRS = new Set(["node_modules", ".git", ".next"]);
const CLOUD_SANDBOX_ROOT_ALLOWLIST = new Set([".env", ".env.local"]);
export type CloudSandboxFileMap = Record<string, string>;
export type CloudSandboxSyncUpdate = {
appId: number;
errorMessage: string | null;
};
const CloudSandboxCreateResponseSchema = z.object({
sandboxId: z.string().trim().min(1),
previewUrl: z.string().trim().min(1),
previewAuthToken: z.string().trim().min(1),
});
const CloudSandboxUploadFilesResponseSchema = z.object({
previewUrl: z.string().trim().min(1).optional(),
previewAuthToken: z.string().trim().min(1).optional(),
});
const CloudSandboxRestartResponseSchema = z.object({
previewUrl: z.string().trim().min(1),
previewAuthToken: z.string().trim().min(1),
});
const CloudSandboxReconcileResponseSchema = z.object({
reconciledSandboxIds: z.array(z.string().trim().min(1)).optional(),
});
const CloudSandboxStatusSchema = z.object({
sandboxId: z.string().trim().min(1),
status: z.string().trim().min(1),
previewUrl: z.string().trim().min(1),
previewAuthToken: z.string().trim().min(1),
previewPort: z.number().int(),
syncRevision: z.number().int().nonnegative(),
initialSyncCompleted: z.boolean(),
appStatus: z.enum(["starting", "running", "standby", "failed"]),
syncAgentHealthy: z.boolean(),
createdAt: z.string().trim().min(1),
lastActiveAt: z.string().trim().min(1),
lastSuccessfulSyncAt: z.string().trim().min(1).nullable(),
expiresAt: z.string().trim().min(1),
billingState: z.enum([
"active",
"charging",
"terminated",
"billing_unavailable",
]),
billingStartedAt: z.string().trim().min(1),
billingLockedAt: z.string().trim().min(1).nullable(),
lastChargedAt: z.string().trim().min(1).nullable(),
nextChargeAt: z.string().trim().min(1),
billingSlicesCharged: z.number().int().nonnegative(),
creditsCharged: z.number().nonnegative(),
terminationReason: z
.enum([
"manual",
"idle_timeout",
"credits_exhausted",
"billing_unavailable",
])
.nullable(),
lastErrorCode: z.string().trim().min(1).nullable(),
lastErrorMessage: z.string().trim().min(1).nullable(),
localSyncErrorMessage: z.string().trim().min(1).nullable().optional(),
});
const CloudSandboxShareLinkSchema = z.object({
sandboxId: z.string().trim().min(1),
shareLinkId: z.string().trim().min(1),
url: z.string().trim().min(1),
expiresAt: z.string().trim().min(1),
});
const ServiceResponseSchema = <T extends z.ZodTypeAny>(schema: T) =>
z.object({
success: z.boolean(),
message: z.string(),
responseObject: schema.optional(),
statusCode: z.number(),
});
export type CloudSandboxStatus = z.infer<typeof CloudSandboxStatusSchema>;
export type CloudSandboxShareLink = z.infer<typeof CloudSandboxShareLinkSchema>;
export class CloudSandboxApiError extends Error {
constructor(
message: string,
readonly code?: string,
readonly status?: number,
) {
super(message);
this.name = "CloudSandboxApiError";
}
}
type ActiveCloudSandbox = {
appId: number;
appPath: string;
sandboxId: string;
previewAuthToken?: string;
};
function getDefaultInstallCommand(): string {
return "pnpm install";
}
function getDefaultStartCommand(): string {
return "pnpm run dev";
}
function getDefaultCloudSandboxErrorMessage(status: number): string {
if (status === 401 || status === 403) {
return "Dyad couldn’t authorize the cloud sandbox request. Please try again.";
}
if (status === 404) {
return "The cloud sandbox could not be found.";
}
if (status === 429) {
return "Dyad is rate limiting cloud sandbox requests right now. Please try again.";
}
if (status >= 500) {
return "Dyad’s cloud sandbox service is temporarily unavailable. Please try again.";
}
return `Cloud sandbox request failed with ${status}.`;
}
function resolveCloudSandboxCommands(input: {
appId: number;
installCommand?: string | null;
startCommand?: string | null;
}): { installCommand: string; startCommand: string } {
return {
installCommand: input.installCommand?.trim() || getDefaultInstallCommand(),
startCommand: input.startCommand?.trim() || getDefaultStartCommand(),
};
}
export interface CloudSandboxProvider {
name: string;
createSandbox(input: {
appId: number;
appPath: string;
installCommand?: string | null;
startCommand?: string | null;
}): Promise<{
sandboxId: string;
previewUrl: string;
previewAuthToken: string;
}>;
destroySandbox(sandboxId: string): Promise<void>;
streamLogs(sandboxId: string, signal?: AbortSignal): AsyncIterable<string>;
uploadFiles(
sandboxId: string,
files: CloudSandboxFileMap,
options?: { replaceAll?: boolean; deletedFiles?: string[] },
): Promise<{ previewUrl?: string; previewAuthToken?: string }>;
restartSandbox(
sandboxId: string,
): Promise<{ previewUrl: string; previewAuthToken: string }>;
getStatus(sandboxId: string): Promise<CloudSandboxStatus>;
createShareLink(
sandboxId: string,
options?: { expiresInSeconds?: number },
): Promise<CloudSandboxShareLink>;
}
const pendingUploads = new Map<
number,
{
activeSandbox: ActiveCloudSandbox;
timeoutId: ReturnType<typeof setTimeout>;
changedPaths: Set<string>;
deletedPaths: Set<string>;
fullSync: boolean;
}
>();
const activeCloudSandboxesByAppId = new Map<number, ActiveCloudSandbox>();
const activeCloudSandboxesByPath = new Map<string, ActiveCloudSandbox>();
let cloudSandboxSyncUpdateListener:
| ((update: CloudSandboxSyncUpdate) => void)
| undefined;
function getDyadEngineApiKey() {
const settings = readSettings();
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey && !IS_TEST_BUILD) {
throw new Error("Dyad Pro API key is required for cloud sandboxes.");
}
return apiKey;
}
async function cloudSandboxFetch(
endpoint: string,
init: RequestInit = {},
): Promise<Response> {
const apiKey = getDyadEngineApiKey();
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
if (apiKey) {
headers.set("Authorization", `Bearer ${apiKey}`);
}
const response = await fetch(`${DYAD_ENGINE_URL}${endpoint}`, {
...init,
headers,
});
if (!response.ok) {
const errorText = await response.text();
let message = getDefaultCloudSandboxErrorMessage(response.status);
let code: string | undefined;
try {
const parsed = JSON.parse(errorText) as {
code?: string;
message?: string;
};
message = parsed.message || message;
code = parsed.code;
} catch {
// Keep the generic status-based message instead of surfacing raw HTML/JSON.
}
throw new CloudSandboxApiError(message, code, response.status);
}
return response;
}
async function parseServiceResponse<T>(
response: Response,
schema: z.ZodType<T>,
context: string,
): Promise<T> {
const parsed = await response.json();
const result = ServiceResponseSchema(schema).safeParse(parsed);
if (!result.success || !result.data.responseObject) {
throw new Error(
`Invalid ${context} response from cloud sandbox API: ${
result.success ? "Missing responseObject" : result.error.message
}`,
);
}
return result.data.responseObject;
}
async function parseResponseJson<T>(
response: Response,
schema: z.ZodType<T>,
context: string,
): Promise<T> {
const parsed = await response.json();
const result = schema.safeParse(parsed);
if (!result.success) {
throw new Error(
`Invalid ${context} response from cloud sandbox API: ${result.error.message}`,
);
}
return result.data;
}
export async function buildCloudSandboxFileMap(
appPath: string,
): Promise<CloudSandboxFileMap> {
const files = (await collectCloudSandboxFiles(appPath, appPath)).sort();
const entries = await Promise.all(
files.map(async (relativePath) => {
const normalizedPath = normalizePath(relativePath);
const fullPath = path.join(appPath, normalizedPath);
const content = await fsPromises.readFile(fullPath, "utf-8");
return [normalizedPath, content] as const;
}),
);
return Object.fromEntries(entries);
}
function isRootCloudSandboxAllowlisted(relativePath: string): boolean {
return CLOUD_SANDBOX_ROOT_ALLOWLIST.has(normalizePath(relativePath));
}
function hasCloudSandboxExcludedSegment(relativePath: string): boolean {
return normalizePath(relativePath)
.split("/")
.some((segment) => CLOUD_SANDBOX_EXCLUDED_DIRS.has(segment));
}
function shouldForceCloudSandboxFullSyncForPath(relativePath: string): boolean {
return path.posix.basename(normalizePath(relativePath)) === ".gitignore";
}
function shouldForceCloudSandboxFullSync(input: {
changedPaths?: Iterable<string>;
deletedPaths?: Iterable<string>;
}): boolean {
for (const relativePath of input.changedPaths ?? []) {
if (shouldForceCloudSandboxFullSyncForPath(relativePath)) {
return true;
}
}
for (const relativePath of input.deletedPaths ?? []) {
if (shouldForceCloudSandboxFullSyncForPath(relativePath)) {
return true;
}
}
return false;
}
async function isCloudSandboxGitIgnored(
appPath: string,
relativePath: string,
): Promise<boolean> {
try {
return await gitIsIgnoredIso({
path: appPath,
filepath: normalizePath(relativePath),
});
} catch (error) {
logger.warn(
`Failed to evaluate gitignore rules for cloud sandbox path ${relativePath}:`,
error,
);
return false;
}
}
async function shouldIncludeCloudSandboxPath(
appPath: string,
relativePath: string,
): Promise<boolean> {
const normalizedPath = normalizePath(relativePath);
if (isRootCloudSandboxAllowlisted(normalizedPath)) {
return true;
}
if (hasCloudSandboxExcludedSegment(normalizedPath)) {
return false;
}
return !(await isCloudSandboxGitIgnored(appPath, normalizedPath));
}
async function collectCloudSandboxFiles(
dir: string,
appPath: string,
): Promise<string[]> {
let entries;
try {
entries = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
const nestedFiles = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
const relativePath = normalizePath(path.relative(appPath, fullPath));
if (entry.isSymbolicLink()) {
return [];
}
if (entry.isDirectory()) {
if (CLOUD_SANDBOX_EXCLUDED_DIRS.has(entry.name)) {
return [];
}
if (!(await shouldIncludeCloudSandboxPath(appPath, relativePath))) {
return [];
}
return collectCloudSandboxFiles(fullPath, appPath);
}
if (!entry.isFile()) {
return [];
}
if (!(await shouldIncludeCloudSandboxPath(appPath, relativePath))) {
return [];
}
return [relativePath];
}),
);
return nestedFiles.flat();
}
async function buildCloudSandboxPartialFileMap(input: {
appPath: string;
changedPaths: Iterable<string>;
}): Promise<{ files: CloudSandboxFileMap; deletedFiles: string[] }> {
const files: CloudSandboxFileMap = {};
const deletedFiles = new Set<string>();
for (const relativePath of input.changedPaths) {
const normalizedPath = normalizePath(relativePath);
const fullPath = path.join(input.appPath, normalizedPath);
try {
const stats = await fsPromises.lstat(fullPath);
if (stats.isSymbolicLink() || !stats.isFile()) {
deletedFiles.add(normalizedPath);
continue;
}
if (
hasCloudSandboxExcludedSegment(normalizedPath) ||
!(await shouldIncludeCloudSandboxPath(input.appPath, normalizedPath))
) {
deletedFiles.add(normalizedPath);
continue;
}
const content = await fsPromises.readFile(fullPath, "utf-8");
files[normalizedPath] = content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
deletedFiles.add(normalizedPath);
continue;
}
throw error;
}
}
return {
files,
deletedFiles: [...deletedFiles].sort(),
};
}
async function* parseSseLines(response: Response, signal?: AbortSignal) {
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (signal?.aborted) {
await reader.cancel();
return;
}
buffered += decoder.decode(value, { stream: true });
while (buffered.includes("\n\n")) {
const boundary = buffered.indexOf("\n\n");
const rawEvent = buffered.slice(0, boundary);
buffered = buffered.slice(boundary + 2);
const dataLines = rawEvent
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) {
continue;
}
const payload = dataLines.join("\n");
if (payload === "[DONE]") {
return;
}
yield payload;
}
}
}
function resolveActiveCloudSandbox(input: {
appId?: number;
appPath?: string;
}): ActiveCloudSandbox | undefined {
return (
(input.appId !== undefined
? activeCloudSandboxesByAppId.get(input.appId)
: undefined) ??
(input.appPath
? activeCloudSandboxesByPath.get(path.resolve(input.appPath))
: undefined)
);
}
async function uploadFullSnapshot(activeSandbox: ActiveCloudSandbox) {
const files = await buildCloudSandboxFileMap(activeSandbox.appPath);
await uploadCloudSandboxFiles({
sandboxId: activeSandbox.sandboxId,
files,
replaceAll: true,
});
notifyCloudSandboxSyncUpdate({
appId: activeSandbox.appId,
errorMessage: null,
});
}
async function uploadPendingSnapshot(input: {
activeSandbox: ActiveCloudSandbox;
changedPaths: Set<string>;
deletedPaths: Set<string>;
fullSync: boolean;
}) {
if (input.fullSync) {
await uploadFullSnapshot(input.activeSandbox);
logger.info(
`Synced full app snapshot to cloud sandbox ${input.activeSandbox.sandboxId} for app ${input.activeSandbox.appId}.`,
);
return;
}
const { files, deletedFiles: missingChangedFiles } =
await buildCloudSandboxPartialFileMap({
appPath: input.activeSandbox.appPath,
changedPaths: input.changedPaths,
});
const deletedFiles = [
...new Set([...input.deletedPaths, ...missingChangedFiles]),
].sort();
if (Object.keys(files).length === 0 && deletedFiles.length === 0) {
return;
}
await uploadCloudSandboxFiles({
sandboxId: input.activeSandbox.sandboxId,
files,
deletedFiles,
replaceAll: false,
});
notifyCloudSandboxSyncUpdate({
appId: input.activeSandbox.appId,
errorMessage: null,
});
logger.info(
`Synced incremental app snapshot to cloud sandbox ${input.activeSandbox.sandboxId} for app ${input.activeSandbox.appId}. fileCount=${Object.keys(files).length} deletedCount=${deletedFiles.length}.`,
);
}
export async function syncCloudSandboxSnapshot(input: {
appId?: number;
appPath?: string;
}): Promise<void> {
const activeSandbox = resolveActiveCloudSandbox(input);
if (!activeSandbox) {
return;
}
try {
stopCloudSandboxFileSync(activeSandbox.appId);
await uploadFullSnapshot(activeSandbox);
logger.info(
`Synced full app snapshot to cloud sandbox ${activeSandbox.sandboxId} for app ${activeSandbox.appId}.`,
);
} catch (error) {
notifyCloudSandboxSyncUpdate({
appId: activeSandbox.appId,
errorMessage: formatCloudSandboxSyncError(error),
});
throw error;
}
}
export async function syncCloudSandboxDirtyPaths(input: {
appId?: number;
appPath?: string;
changedPaths?: string[];
deletedPaths?: string[];
}): Promise<void> {
const activeSandbox = resolveActiveCloudSandbox(input);
if (!activeSandbox) {
return;
}
const changedPaths = new Set(
(input.changedPaths ?? []).map((changedPath) => normalizePath(changedPath)),
);
const deletedPaths = new Set(
(input.deletedPaths ?? []).map((deletedPath) => normalizePath(deletedPath)),
);
try {
stopCloudSandboxFileSync(activeSandbox.appId);
await uploadPendingSnapshot({
activeSandbox,
changedPaths,
deletedPaths,
fullSync: shouldForceCloudSandboxFullSync({ changedPaths, deletedPaths }),
});
} catch (error) {
notifyCloudSandboxSyncUpdate({
appId: activeSandbox.appId,
errorMessage: formatCloudSandboxSyncError(error),
});
throw error;
}
}
class DyadEngineCloudSandboxProvider implements CloudSandboxProvider {
name = "dyad-engine";
async createSandbox(input: {
appId: number;
appPath: string;
installCommand?: string | null;
startCommand?: string | null;
}) {
const { installCommand, startCommand } = resolveCloudSandboxCommands(input);
const response = await cloudSandboxFetch("/sandboxes", {
method: "POST",
body: JSON.stringify({
appId: input.appId,
appPath: input.appPath,
installCommand,
startCommand,
}),
});
return parseResponseJson(
response,
CloudSandboxCreateResponseSchema,
"create sandbox",
);
}
async destroySandbox(sandboxId: string) {
await cloudSandboxFetch(`/sandboxes/${sandboxId}`, {
method: "DELETE",
});
}
async *streamLogs(sandboxId: string, signal?: AbortSignal) {
const response = await cloudSandboxFetch(`/sandboxes/${sandboxId}/logs`, {
headers: {
Accept: "text/event-stream",
},
signal,
});
for await (const payload of parseSseLines(response, signal)) {
try {
const parsed = JSON.parse(payload) as { message?: string };
yield parsed.message ?? payload;
} catch {
yield payload;
}
}
}
async uploadFiles(
sandboxId: string,
files: CloudSandboxFileMap,
options?: { replaceAll?: boolean; deletedFiles?: string[] },
) {
const response = await cloudSandboxFetch(`/sandboxes/${sandboxId}/files`, {
method: "POST",
body: JSON.stringify({
files,
replaceAll: options?.replaceAll ?? false,
deletedFiles: options?.deletedFiles ?? [],
}),
});
return parseResponseJson(
response,
CloudSandboxUploadFilesResponseSchema,
"upload sandbox files",
);
}
async restartSandbox(sandboxId: string) {
const response = await cloudSandboxFetch(
`/sandboxes/${sandboxId}/restart`,
{
method: "POST",
},
);
return parseResponseJson(
response,
CloudSandboxRestartResponseSchema,
"restart sandbox",
);
}
async getStatus(sandboxId: string) {
const response = await cloudSandboxFetch(`/sandboxes/${sandboxId}/status`);
return parseServiceResponse(
response,
CloudSandboxStatusSchema,
"cloud sandbox status",
);
}
async createShareLink(
sandboxId: string,
options?: { expiresInSeconds?: number },
) {
const response = await cloudSandboxFetch(
`/sandboxes/${sandboxId}/share-links`,
{
method: "POST",
body: JSON.stringify({
expiresInSeconds: options?.expiresInSeconds,
}),
},
);
return parseServiceResponse(
response,
CloudSandboxShareLinkSchema,
"cloud sandbox share link",
);
}
}
const defaultProvider: CloudSandboxProvider =
new DyadEngineCloudSandboxProvider();
export async function destroyCloudSandbox(sandboxId: string): Promise<void> {
await defaultProvider.destroySandbox(sandboxId);
}
export async function createCloudSandbox(input: {
appId: number;
appPath: string;
installCommand?: string | null;
startCommand?: string | null;
}) {
return defaultProvider.createSandbox(input);
}
export async function uploadCloudSandboxFiles(input: {
sandboxId: string;
files: CloudSandboxFileMap;
replaceAll?: boolean;
deletedFiles?: string[];
}) {
return defaultProvider.uploadFiles(input.sandboxId, input.files, {
replaceAll: input.replaceAll,
deletedFiles: input.deletedFiles,
});
}
export async function restartCloudSandbox(sandboxId: string) {
return defaultProvider.restartSandbox(sandboxId);
}
export function streamCloudSandboxLogs(
sandboxId: string,
signal?: AbortSignal,
) {
return defaultProvider.streamLogs(sandboxId, signal);
}
export async function getCloudSandboxStatus(
sandboxId: string,
): Promise<CloudSandboxStatus> {
return defaultProvider.getStatus(sandboxId);
}
export async function createCloudSandboxShareLink(
sandboxId: string,
options?: { expiresInSeconds?: number },
): Promise<CloudSandboxShareLink> {
return defaultProvider.createShareLink(sandboxId, options);
}
export function setCloudSandboxSyncUpdateListener(
listener?: (update: CloudSandboxSyncUpdate) => void,
): void {
cloudSandboxSyncUpdateListener = listener;
}
function notifyCloudSandboxSyncUpdate(update: CloudSandboxSyncUpdate): void {
cloudSandboxSyncUpdateListener?.(update);
}
function formatCloudSandboxSyncError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `Cloud sandbox sync failed: ${message}`;
}
export function registerRunningCloudSandbox(input: ActiveCloudSandbox): void {
const activeSandbox = {
...input,
appPath: path.resolve(input.appPath),
};
activeCloudSandboxesByAppId.set(activeSandbox.appId, activeSandbox);
activeCloudSandboxesByPath.set(activeSandbox.appPath, activeSandbox);
}
export function unregisterRunningCloudSandbox(input: {
appId: number;
appPath?: string;
}): void {
const existing = activeCloudSandboxesByAppId.get(input.appId);
if (existing) {
activeCloudSandboxesByPath.delete(existing.appPath);
}
if (input.appPath) {
activeCloudSandboxesByPath.delete(path.resolve(input.appPath));
}
activeCloudSandboxesByAppId.delete(input.appId);
}
export function stopCloudSandboxFileSync(appId: number): void {
const pending = pendingUploads.get(appId);
if (!pending) {
return;
}
clearTimeout(pending.timeoutId);
pendingUploads.delete(appId);
}
export function queueCloudSandboxSnapshotSync(input: {
appId?: number;
appPath?: string;
immediate?: boolean;
changedPaths?: string[];
deletedPaths?: string[];
fullSync?: boolean;
}): void {
const activeSandbox = resolveActiveCloudSandbox(input);
if (!activeSandbox) {
return;
}
const existing = pendingUploads.get(activeSandbox.appId);
if (existing) {
clearTimeout(existing.timeoutId);
}
const changedPaths = existing?.changedPaths ?? new Set<string>();
const deletedPaths = existing?.deletedPaths ?? new Set<string>();
for (const changedPath of input.changedPaths ?? []) {
const normalizedPath = normalizePath(changedPath);
changedPaths.add(normalizedPath);
deletedPaths.delete(normalizedPath);
}
for (const deletedPath of input.deletedPaths ?? []) {
const normalizedPath = normalizePath(deletedPath);
deletedPaths.add(normalizedPath);
changedPaths.delete(normalizedPath);
}
const fullSync =
input.fullSync === true ||
existing?.fullSync === true ||
shouldForceCloudSandboxFullSync({
changedPaths,
deletedPaths,
});
const timeoutId = setTimeout(
async () => {
const pending = pendingUploads.get(activeSandbox.appId);
pendingUploads.delete(activeSandbox.appId);
if (!pending) {
return;
}
try {
if (pending.fullSync) {
await uploadPendingSnapshot({
activeSandbox: pending.activeSandbox,
changedPaths: pending.changedPaths,
deletedPaths: pending.deletedPaths,
fullSync: true,
});
} else {
await uploadPendingSnapshot({
activeSandbox: pending.activeSandbox,
changedPaths: pending.changedPaths,
deletedPaths: pending.deletedPaths,
fullSync: false,
});
}
} catch (error) {
logger.error(
`Failed to sync app snapshot to cloud sandbox ${activeSandbox.sandboxId} for app ${activeSandbox.appId}:`,
error,
);
notifyCloudSandboxSyncUpdate({
appId: pending.activeSandbox.appId,
errorMessage: formatCloudSandboxSyncError(error),
});
}
},
input.immediate ? 0 : 300,
);
pendingUploads.set(activeSandbox.appId, {
activeSandbox,
timeoutId,
changedPaths,
deletedPaths,
fullSync,
});
}
export async function reconcileCloudSandboxes(): Promise<string[]> {
try {
const response = await cloudSandboxFetch("/sandboxes/reconcile", {
method: "POST",
});
const result = await parseResponseJson(
response,
CloudSandboxReconcileResponseSchema,
"reconcile sandboxes",
);
return result.reconciledSandboxIds ?? [];
} catch (error) {
if (error instanceof CloudSandboxApiError && error.status === 404) {
return [];
}
throw error;
}
}
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
destroyCloudSandboxMock,
stopCloudSandboxFileSyncMock,
unregisterRunningCloudSandboxMock,
} = vi.hoisted(() => ({
destroyCloudSandboxMock: vi.fn(),
stopCloudSandboxFileSyncMock: vi.fn(),
unregisterRunningCloudSandboxMock: vi.fn(),
}));
vi.mock("./cloud_sandbox_provider", () => ({
destroyCloudSandbox: destroyCloudSandboxMock,
stopCloudSandboxFileSync: stopCloudSandboxFileSyncMock,
unregisterRunningCloudSandbox: unregisterRunningCloudSandboxMock,
}));
import {
runningApps,
stopAppByInfo,
type RunningAppInfo,
} from "./process_manager";
describe("stopAppByInfo", () => {
beforeEach(() => {
runningApps.clear();
vi.clearAllMocks();
});
it("keeps cloud apps registered when sandbox teardown fails", async () => {
destroyCloudSandboxMock.mockRejectedValueOnce(new Error("teardown failed"));
const abortCloudLogs = vi.fn();
const cloudLogAbortController = {
abort: abortCloudLogs,
} as unknown as AbortController;
const terminateProxyWorker = vi.fn().mockResolvedValue(0);
const proxyWorker = {
terminate: terminateProxyWorker,
} as unknown as NonNullable<RunningAppInfo["proxyWorker"]>;
const appInfo: RunningAppInfo = {
process: null,
processId: 1,
mode: "cloud",
cloudSandboxId: "sandbox-1",
lastViewedAt: Date.now(),
cloudLogAbortController,
proxyWorker,
};
runningApps.set(1, appInfo);
await expect(stopAppByInfo(1, appInfo)).rejects.toThrow("teardown failed");
expect(runningApps.get(1)).toBe(appInfo);
expect(stopCloudSandboxFileSyncMock).toHaveBeenCalledWith(1);
expect(unregisterRunningCloudSandboxMock).not.toHaveBeenCalled();
expect(terminateProxyWorker).not.toHaveBeenCalled();
expect(abortCloudLogs).not.toHaveBeenCalled();
});
it("removes cloud apps after sandbox teardown succeeds", async () => {
const abortCloudLogs = vi.fn();
const cloudLogAbortController = {
abort: abortCloudLogs,
} as unknown as AbortController;
const terminateProxyWorker = vi.fn().mockResolvedValue(0);
const proxyWorker = {
terminate: terminateProxyWorker,
} as unknown as NonNullable<RunningAppInfo["proxyWorker"]>;
const appInfo: RunningAppInfo = {
process: null,
processId: 1,
mode: "cloud",
cloudSandboxId: "sandbox-1",
lastViewedAt: Date.now(),
cloudLogAbortController,
proxyWorker,
};
runningApps.set(1, appInfo);
await stopAppByInfo(1, appInfo);
expect(destroyCloudSandboxMock).toHaveBeenCalledWith("sandbox-1");
expect(stopCloudSandboxFileSyncMock).toHaveBeenCalledWith(1);
expect(terminateProxyWorker).toHaveBeenCalled();
expect(abortCloudLogs).toHaveBeenCalled();
expect(unregisterRunningCloudSandboxMock).toHaveBeenCalledWith({
appId: 1,
});
expect(runningApps.has(1)).toBe(false);
});
});
...@@ -2,16 +2,29 @@ import { ChildProcess, spawn } from "node:child_process"; ...@@ -2,16 +2,29 @@ import { ChildProcess, spawn } from "node:child_process";
import treeKill from "tree-kill"; import treeKill from "tree-kill";
import log from "electron-log"; import log from "electron-log";
import type { Worker } from "node:worker_threads"; import type { Worker } from "node:worker_threads";
import type { RuntimeMode2 } from "@/lib/schemas";
import { withLock } from "./lock_utils"; import { withLock } from "./lock_utils";
import {
destroyCloudSandbox,
stopCloudSandboxFileSync,
unregisterRunningCloudSandbox,
} from "./cloud_sandbox_provider";
const logger = log.scope("process_manager"); const logger = log.scope("process_manager");
// Define a type for the value stored in runningApps // Define a type for the value stored in runningApps
export interface RunningAppInfo { export interface RunningAppInfo {
process: ChildProcess; process: ChildProcess | null;
processId: number; processId: number;
isDocker: boolean; mode: RuntimeMode2;
rendererSender?: Electron.WebContents;
containerName?: string; containerName?: string;
cloudSandboxId?: string;
cloudPreviewUrl?: string;
cloudPreviewAuthToken?: string;
proxyAuthToken?: string;
cloudSyncErrorMessage?: string;
cloudLogAbortController?: AbortController;
/** Timestamp of when this app was last viewed/selected in the preview panel */ /** Timestamp of when this app was last viewed/selected in the preview panel */
lastViewedAt: number; lastViewedAt: number;
/** Proxy URL for the running app, set when the proxy server starts */ /** Proxy URL for the running app, set when the proxy server starts */
...@@ -128,17 +141,27 @@ export async function stopAppByInfo( ...@@ -128,17 +141,27 @@ export async function stopAppByInfo(
appId: number, appId: number,
appInfo: RunningAppInfo, appInfo: RunningAppInfo,
): Promise<void> { ): Promise<void> {
if (appInfo.proxyWorker) { stopCloudSandboxFileSync(appId);
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
if (appInfo.isDocker) { if (appInfo.mode === "cloud") {
if (appInfo.cloudSandboxId) {
await destroyCloudSandbox(appInfo.cloudSandboxId);
}
} else if (appInfo.mode === "docker") {
const containerName = appInfo.containerName || `dyad-app-${appId}`; const containerName = appInfo.containerName || `dyad-app-${appId}`;
await stopDockerContainer(containerName); await stopDockerContainer(containerName);
} else { } else if (appInfo.process) {
await killProcess(appInfo.process); await killProcess(appInfo.process);
} }
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
appInfo.cloudLogAbortController?.abort();
appInfo.cloudLogAbortController = undefined;
unregisterRunningCloudSandbox({ appId });
runningApps.delete(appId); runningApps.delete(appId);
} }
...@@ -157,6 +180,10 @@ export function removeAppIfCurrentProcess( ...@@ -157,6 +180,10 @@ export function removeAppIfCurrentProcess(
void currentAppInfo.proxyWorker.terminate(); void currentAppInfo.proxyWorker.terminate();
currentAppInfo.proxyWorker = undefined; currentAppInfo.proxyWorker = undefined;
} }
currentAppInfo.cloudLogAbortController?.abort();
currentAppInfo.cloudLogAbortController = undefined;
stopCloudSandboxFileSync(appId);
unregisterRunningCloudSandbox({ appId });
runningApps.delete(appId); runningApps.delete(appId);
logger.info( logger.info(
`Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`, `Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`,
...@@ -334,7 +361,22 @@ export function stopAllAppsSync(): void { ...@@ -334,7 +361,22 @@ export function stopAllAppsSync(): void {
appInfo.proxyWorker = undefined; appInfo.proxyWorker = undefined;
} }
if (appInfo.isDocker) { if (appInfo.mode === "cloud") {
appInfo.cloudLogAbortController?.abort();
appInfo.cloudLogAbortController = undefined;
stopCloudSandboxFileSync(appId);
unregisterRunningCloudSandbox({ appId });
if (appInfo.cloudSandboxId) {
void destroyCloudSandbox(appInfo.cloudSandboxId).catch((error) => {
logger.warn(
`Failed to destroy cloud sandbox ${appInfo.cloudSandboxId} for app ${appId} during quit: ${error}`,
);
});
}
logger.info(
`Cloud sandbox ${appInfo.cloudSandboxId ?? "<unknown>"} for app ${appId} will be reconciled asynchronously after quit if needed.`,
);
} else if (appInfo.mode === "docker") {
const containerName = appInfo.containerName || `dyad-app-${appId}`; const containerName = appInfo.containerName || `dyad-app-${appId}`;
// Fire-and-forget: spawn docker stop without awaiting // Fire-and-forget: spawn docker stop without awaiting
const stop = spawn("docker", ["stop", containerName], { const stop = spawn("docker", ["stop", containerName], {
...@@ -346,16 +388,17 @@ export function stopAllAppsSync(): void { ...@@ -346,16 +388,17 @@ export function stopAllAppsSync(): void {
); );
}); });
logger.info(`Sent docker stop for app ${appId} (${containerName})`); logger.info(`Sent docker stop for app ${appId} (${containerName})`);
} else if (appInfo.process.pid) { } else if (appInfo.process?.pid) {
const pid = appInfo.process.pid;
// treeKill sends SIGTERM synchronously // treeKill sends SIGTERM synchronously
treeKill(appInfo.process.pid, "SIGTERM", (err: Error | undefined) => { treeKill(pid, "SIGTERM", (err: Error | undefined) => {
if (err) { if (err) {
logger.warn( logger.warn(
`tree-kill error for app ${appId} (PID ${appInfo.process.pid}): ${err.message}`, `tree-kill error for app ${appId} (PID ${pid}): ${err.message}`,
); );
} }
}); });
logger.info(`Sent SIGTERM to app ${appId} (PID ${appInfo.process.pid})`); logger.info(`Sent SIGTERM to app ${appId} (PID ${pid})`);
} }
runningApps.delete(appId); runningApps.delete(appId);
} }
......
...@@ -15,6 +15,7 @@ export async function startProxy( ...@@ -15,6 +15,7 @@ export async function startProxy(
// port?: number; // port?: number;
// env?: Record<string, string>; // env?: Record<string, string>;
onStarted?: (proxyUrl: string) => void; onStarted?: (proxyUrl: string) => void;
fixedHeaders?: Record<string, string>;
} = {}, } = {},
) { ) {
if (!/^https?:\/\//.test(targetOrigin)) if (!/^https?:\/\//.test(targetOrigin))
...@@ -28,6 +29,7 @@ export async function startProxy( ...@@ -28,6 +29,7 @@ export async function startProxy(
// host = "localhost", // host = "localhost",
// env = {}, // additional env vars to pass to the worker // env = {}, // additional env vars to pass to the worker
onStarted, onStarted,
fixedHeaders,
} = opts; } = opts;
const worker = new Worker( const worker = new Worker(
...@@ -36,6 +38,7 @@ export async function startProxy( ...@@ -36,6 +38,7 @@ export async function startProxy(
workerData: { workerData: {
targetOrigin, targetOrigin,
port, port,
fixedHeaders,
}, },
}, },
); );
......
...@@ -225,6 +225,11 @@ export const queryKeys = { ...@@ -225,6 +225,11 @@ export const queryKeys = {
info: ["userBudgetInfo"] as const, info: ["userBudgetInfo"] as const,
}, },
cloudSandboxes: {
status: ({ appId }: { appId: number | null }) =>
["cloudSandboxStatus", appId] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Free Agent Quota // Free Agent Quota
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -360,6 +365,9 @@ export type AppQueryKey = ...@@ -360,6 +365,9 @@ export type AppQueryKey =
(typeof queryKeys.languageModels)[keyof typeof queryKeys.languageModels] (typeof queryKeys.languageModels)[keyof typeof queryKeys.languageModels]
> >
| QueryKeyOf<(typeof queryKeys.userBudget)[keyof typeof queryKeys.userBudget]> | QueryKeyOf<(typeof queryKeys.userBudget)[keyof typeof queryKeys.userBudget]>
| QueryKeyOf<
(typeof queryKeys.cloudSandboxes)[keyof typeof queryKeys.cloudSandboxes]
>
| QueryKeyOf< | QueryKeyOf<
(typeof queryKeys.freeAgentQuota)[keyof typeof queryKeys.freeAgentQuota] (typeof queryKeys.freeAgentQuota)[keyof typeof queryKeys.freeAgentQuota]
> >
......
...@@ -141,7 +141,7 @@ export type VertexProviderSetting = z.infer<typeof VertexProviderSettingSchema>; ...@@ -141,7 +141,7 @@ export type VertexProviderSetting = z.infer<typeof VertexProviderSettingSchema>;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]); export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>; export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const RuntimeMode2Schema = z.enum(["host", "docker"]); export const RuntimeMode2Schema = z.enum(["host", "docker", "cloud"]);
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>; export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
/** /**
...@@ -213,6 +213,7 @@ export const ExperimentsSchema = z.object({ ...@@ -213,6 +213,7 @@ export const ExperimentsSchema = z.object({
enableLocalAgent: z.boolean().describe("DEPRECATED").optional(), enableLocalAgent: z.boolean().describe("DEPRECATED").optional(),
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(), enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
enableFileEditing: z.boolean().describe("DEPRECATED").optional(), enableFileEditing: z.boolean().describe("DEPRECATED").optional(),
enableCloudSandbox: z.boolean().optional(),
}); });
export type Experiments = z.infer<typeof ExperimentsSchema>; export type Experiments = z.infer<typeof ExperimentsSchema>;
......
...@@ -6,6 +6,30 @@ import { ...@@ -6,6 +6,30 @@ import {
} from "./settingsSearchIndex"; } from "./settingsSearchIndex";
describe("SETTINGS_SEARCH_INDEX", () => { describe("SETTINGS_SEARCH_INDEX", () => {
it("includes the cloud sandbox experiment", () => {
expect(
SETTINGS_SEARCH_INDEX.find(
(item) => item.id === SETTING_IDS.enableCloudSandbox,
),
).toEqual({
id: SETTING_IDS.enableCloudSandbox,
label: "Enable Cloud Sandbox (Pro)",
description:
"Run your app on the Cloud for a more secure runtime that uses fewer local system resources",
keywords: [
"cloud",
"sandbox",
"runtime",
"experiment",
"pro",
"credits",
"secure",
],
sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments",
});
});
it("includes the block unsafe npm packages experiment", () => { it("includes the block unsafe npm packages experiment", () => {
expect( expect(
SETTINGS_SEARCH_INDEX.find( SETTINGS_SEARCH_INDEX.find(
......
...@@ -34,6 +34,7 @@ export const SETTING_IDS = { ...@@ -34,6 +34,7 @@ export const SETTING_IDS = {
supabase: "setting-supabase", supabase: "setting-supabase",
neon: "setting-neon", neon: "setting-neon",
nativeGit: "setting-native-git", nativeGit: "setting-native-git",
enableCloudSandbox: "setting-enable-cloud-sandbox",
blockUnsafeNpmPackages: "setting-block-unsafe-npm-packages", blockUnsafeNpmPackages: "setting-block-unsafe-npm-packages",
enableMcpServersForBuildMode: "setting-enable-mcp-servers-for-build-mode", enableMcpServersForBuildMode: "setting-enable-mcp-servers-for-build-mode",
enableSelectAppFromHomeChatInput: enableSelectAppFromHomeChatInput:
...@@ -342,6 +343,23 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [ ...@@ -342,6 +343,23 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.experiments, sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments", sectionLabel: "Experiments",
}, },
{
id: SETTING_IDS.enableCloudSandbox,
label: "Enable Cloud Sandbox (Pro)",
description:
"Run your app on the Cloud for a more secure runtime that uses fewer local system resources",
keywords: [
"cloud",
"sandbox",
"runtime",
"experiment",
"pro",
"credits",
"secure",
],
sectionId: SECTION_IDS.experiments,
sectionLabel: "Experiments",
},
{ {
id: SETTING_IDS.blockUnsafeNpmPackages, id: SETTING_IDS.blockUnsafeNpmPackages,
label: "Block unsafe npm packages", label: "Block unsafe npm packages",
......
...@@ -35,6 +35,7 @@ import { LanguageSelector } from "@/components/LanguageSelector"; ...@@ -35,6 +35,7 @@ import { LanguageSelector } from "@/components/LanguageSelector";
import { DefaultChatModeSelector } from "@/components/DefaultChatModeSelector"; import { DefaultChatModeSelector } from "@/components/DefaultChatModeSelector";
import { ContextCompactionSwitch } from "@/components/ContextCompactionSwitch"; import { ContextCompactionSwitch } from "@/components/ContextCompactionSwitch";
import { BlockUnsafeNpmPackagesSwitch } from "@/components/BlockUnsafeNpmPackagesSwitch"; import { BlockUnsafeNpmPackagesSwitch } from "@/components/BlockUnsafeNpmPackagesSwitch";
import { CloudSandboxExperimentSwitch } from "@/components/CloudSandboxExperimentSwitch";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms"; import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
import { SECTION_IDS, SETTING_IDS } from "@/lib/settingsSearchIndex"; import { SECTION_IDS, SETTING_IDS } from "@/lib/settingsSearchIndex";
...@@ -196,6 +197,12 @@ export default function SettingsPage() { ...@@ -196,6 +197,12 @@ export default function SettingsPage() {
a faster, native-Git performance experience. a faster, native-Git performance experience.
</div> </div>
</div> </div>
<div
id={SETTING_IDS.enableCloudSandbox}
className="space-y-1 mt-4"
>
<CloudSandboxExperimentSwitch />
</div>
<div <div
id={SETTING_IDS.blockUnsafeNpmPackages} id={SETTING_IDS.blockUnsafeNpmPackages}
className="space-y-1 mt-4" className="space-y-1 mt-4"
......
import { z } from "zod"; import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types"; import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { executeCopyFile } from "@/ipc/utils/copy_file_utils"; import { executeCopyFile } from "@/ipc/utils/copy_file_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const copyFileSchema = z.object({ const copyFileSchema = z.object({
from: z from: z
...@@ -45,6 +46,11 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = { ...@@ -45,6 +46,11 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = {
ctx.isSharedModulesChanged = true; ctx.isSharedModulesChanged = true;
} }
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.to],
});
if (result.deployError) { if (result.deployError) {
return `File copied, but failed to deploy Supabase function: ${result.deployError}`; return `File copied, but failed to deploy Supabase function: ${result.deployError}`;
} }
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
isSharedServerModule, isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils"; } from "../../../../../../supabase_admin/supabase_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("delete_file"); const logger = log.scope("delete_file");
...@@ -95,6 +96,11 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> = ...@@ -95,6 +96,11 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
logger.warn(`File to delete does not exist: ${fullFilePath}`); logger.warn(`File to delete does not exist: ${fullFilePath}`);
} }
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
deletedPaths: [args.path],
});
return `Successfully deleted ${args.path}`; return `Successfully deleted ${args.path}`;
}, },
}; };
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
} from "../../../../../../supabase_admin/supabase_utils"; } from "../../../../../../supabase_admin/supabase_utils";
import { engineFetch } from "./engine_fetch"; import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const readFile = fs.promises.readFile; const readFile = fs.promises.readFile;
const logger = log.scope("edit_file"); const logger = log.scope("edit_file");
...@@ -200,6 +201,10 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = { ...@@ -200,6 +201,10 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
// Write file content // Write file content
fs.writeFileSync(fullFilePath, newContent); fs.writeFileSync(fullFilePath, newContent);
logger.log(`Successfully edited file: ${fullFilePath}`); logger.log(`Successfully edited file: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.path],
});
// Deploy Supabase function if applicable // Deploy Supabase function if applicable
if ( if (
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
isServerFunction, isServerFunction,
isSharedServerModule, isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils"; } from "../../../../../../supabase_admin/supabase_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("rename_file"); const logger = log.scope("rename_file");
...@@ -100,6 +101,12 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> = ...@@ -100,6 +101,12 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
logger.warn(`Source file for rename does not exist: ${fromFullPath}`); logger.warn(`Source file for rename does not exist: ${fromFullPath}`);
} }
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.to],
deletedPaths: [args.from],
});
return `Successfully renamed ${args.from} to ${args.to}`; return `Successfully renamed ${args.from} to ${args.to}`;
}, },
}; };
...@@ -18,6 +18,7 @@ import { ...@@ -18,6 +18,7 @@ import {
} from "@/supabase_admin/supabase_utils"; } from "@/supabase_admin/supabase_utils";
import { sendTelemetryEvent } from "@/ipc/utils/telemetry"; import { sendTelemetryEvent } from "@/ipc/utils/telemetry";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("search_replace"); const logger = log.scope("search_replace");
...@@ -133,6 +134,10 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL: ...@@ -133,6 +134,10 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
await fs.promises.writeFile(fullFilePath, result.content); await fs.promises.writeFile(fullFilePath, result.content);
logger.log(`Successfully applied search-replace to: ${fullFilePath}`); logger.log(`Successfully applied search-replace to: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.file_path],
});
sendTelemetryEvent("local_agent:search_replace:success", { sendTelemetryEvent("local_agent:search_replace:success", {
filePath: args.file_path, filePath: args.file_path,
}); });
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
isServerFunction, isServerFunction,
isSharedServerModule, isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils"; } from "../../../../../../supabase_admin/supabase_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("write_file"); const logger = log.scope("write_file");
const writeFileSchema = z.object({ const writeFileSchema = z.object({
...@@ -54,6 +55,10 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = { ...@@ -54,6 +55,10 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
// Write file content // Write file content
fs.writeFileSync(fullFilePath, args.content); fs.writeFileSync(fullFilePath, args.content);
logger.log(`Successfully wrote file: ${fullFilePath}`); logger.log(`Successfully wrote file: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.path],
});
// Deploy Supabase function if applicable // Deploy Supabase function if applicable
if ( if (
......
...@@ -29,6 +29,7 @@ import { ...@@ -29,6 +29,7 @@ import {
} from "../../utils/visual_editing_utils"; } from "../../utils/visual_editing_utils";
import { normalizePath } from "../../../../../shared/normalizePath"; import { normalizePath } from "../../../../../shared/normalizePath";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
// Client allows 7.5 MB raw; base64 expands by ~4/3 plus data URL prefix // Client allows 7.5 MB raw; base64 expands by ~4/3 plus data URL prefix
const MAX_IMAGE_SIZE = Math.ceil((7.5 * 1024 * 1024) / 3) * 4 + 100; // ~10,485,860 const MAX_IMAGE_SIZE = Math.ceil((7.5 * 1024 * 1024) / 3) * 4 + 100; // ~10,485,860
...@@ -167,6 +168,8 @@ export function registerVisualEditingHandlers() { ...@@ -167,6 +168,8 @@ export function registerVisualEditingHandlers() {
}); });
} }
const changedPaths = new Set<string>();
// Apply changes to each file // Apply changes to each file
for (const [relativePath, lineChanges] of fileChanges) { for (const [relativePath, lineChanges] of fileChanges) {
const normalizedRelativePath = normalizePath(relativePath); const normalizedRelativePath = normalizePath(relativePath);
...@@ -174,6 +177,7 @@ export function registerVisualEditingHandlers() { ...@@ -174,6 +177,7 @@ export function registerVisualEditingHandlers() {
const content = await fsPromises.readFile(filePath, "utf-8"); const content = await fsPromises.readFile(filePath, "utf-8");
const transformedContent = transformContent(content, lineChanges); const transformedContent = transformContent(content, lineChanges);
await fsPromises.writeFile(filePath, transformedContent, "utf-8"); await fsPromises.writeFile(filePath, transformedContent, "utf-8");
changedPaths.add(normalizedRelativePath);
// Check if git repository exists and commit the change // Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) { if (fs.existsSync(path.join(appPath, ".git"))) {
await gitAdd({ await gitAdd({
...@@ -187,6 +191,15 @@ export function registerVisualEditingHandlers() { ...@@ -187,6 +191,15 @@ export function registerVisualEditingHandlers() {
}); });
} }
} }
for (const absoluteImagePath of writtenImagePaths) {
changedPaths.add(
normalizePath(path.relative(appPath, absoluteImagePath)),
);
}
queueCloudSandboxSnapshotSync({
appId,
changedPaths: [...changedPaths],
});
} catch (error) { } catch (error) {
// Unstage any image files that were git-added before the failure // Unstage any image files that were git-added before the failure
for (const { appPath, filepath } of stagedGitPaths) { for (const { appPath, filepath } of stagedGitPaths) {
......
import express from "express"; import express from "express";
import { createServer } from "http"; import { createServer } from "http";
import cors from "cors"; import cors from "cors";
import crypto from "node:crypto";
import { createChatCompletionHandler } from "./chatCompletionHandler"; import { createChatCompletionHandler } from "./chatCompletionHandler";
import { createResponsesHandler } from "./responsesHandler"; import { createResponsesHandler } from "./responsesHandler";
import { import {
...@@ -74,6 +75,83 @@ export const CANNED_MESSAGE = ` ...@@ -74,6 +75,83 @@ export const CANNED_MESSAGE = `
More More
EOM`; EOM`;
type FakeCloudSandbox = {
id: string;
files: Record<string, string>;
createdAt: number;
previewAuthToken: string;
syncRevision: number;
initialSyncCompleted: boolean;
lastActiveAt: number;
lastSuccessfulSyncAt: number | null;
};
const cloudSandboxes = new Map<string, FakeCloudSandbox>();
function getFakeCloudPreviewUrl(sandboxId: string) {
return `http://localhost:${PORT}/cloud-preview/${sandboxId}`;
}
function createServiceResponse<T>(responseObject: T) {
return {
success: true,
message: "ok",
responseObject,
statusCode: 200,
};
}
function escapeHtml(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function getSandboxPreviewHtml(sandbox: FakeCloudSandbox) {
const interestingSource =
sandbox.files["src/App.tsx"] ??
sandbox.files["src/App.jsx"] ??
sandbox.files["app/page.tsx"] ??
sandbox.files["index.html"] ??
"";
const fileList = Object.keys(sandbox.files)
.sort()
.slice(0, 12)
.map((file) => `<li>${escapeHtml(file)}</li>`)
.join("");
const snapshotDigest = crypto
.createHash("sha1")
.update(
JSON.stringify(
Object.entries(sandbox.files).sort(([leftPath], [rightPath]) =>
leftPath.localeCompare(rightPath),
),
),
)
.digest("hex")
.slice(0, 12);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Cloud Sandbox Preview</title>
</head>
<body>
<main>
<h1>Cloud Sandbox Preview</h1>
<p data-testid="cloud-sandbox-id">Sandbox: ${escapeHtml(sandbox.id)}</p>
<p>Uploaded files: ${Object.keys(sandbox.files).length}</p>
<p data-testid="cloud-snapshot-digest">Snapshot digest: ${snapshotDigest}</p>
<ul>${fileList}</ul>
<pre>${escapeHtml(interestingSource.slice(0, 1500))}</pre>
</main>
</body>
</html>`;
}
app.get("/health", (req, res) => { app.get("/health", (req, res) => {
res.send("OK"); res.send("OK");
}); });
...@@ -456,6 +534,184 @@ app.post("/engine/v1/tools/web-crawl", (req, res) => { ...@@ -456,6 +534,184 @@ app.post("/engine/v1/tools/web-crawl", (req, res) => {
} }
}); });
app.post("/engine/v1/sandboxes", (_req, res) => {
const sandboxId = `sandbox-${Date.now()}-${Math.round(Math.random() * 1000)}`;
const previewAuthToken = `fake-preview-auth-token-${sandboxId}`;
const createdAt = Date.now();
cloudSandboxes.set(sandboxId, {
id: sandboxId,
files: {},
createdAt,
previewAuthToken,
syncRevision: 0,
initialSyncCompleted: false,
lastActiveAt: createdAt,
lastSuccessfulSyncAt: null,
});
res.json({
sandboxId,
previewUrl: getFakeCloudPreviewUrl(sandboxId),
previewAuthToken,
});
});
app.delete("/engine/v1/sandboxes/:sandboxId", (req, res) => {
cloudSandboxes.delete(req.params.sandboxId);
res.status(204).end();
});
app.post("/engine/v1/sandboxes/:sandboxId/files", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" });
return;
}
console.log(
`[fake-cloud] upload sandbox=${sandbox.id} replaceAll=${String(req.body.replaceAll)} fileCount=${Object.keys(req.body.files ?? {}).length} deletedCount=${(req.body.deletedFiles ?? []).length}`,
);
sandbox.lastActiveAt = Date.now();
sandbox.lastSuccessfulSyncAt = Date.now();
sandbox.initialSyncCompleted = true;
sandbox.syncRevision += 1;
sandbox.files = req.body.replaceAll
? { ...req.body.files }
: {
...sandbox.files,
...req.body.files,
};
for (const deletedFile of req.body.deletedFiles ?? []) {
delete sandbox.files[deletedFile];
}
res.json({
previewUrl: getFakeCloudPreviewUrl(sandbox.id),
previewAuthToken: sandbox.previewAuthToken,
});
});
app.post("/engine/v1/sandboxes/reconcile", (_req, res) => {
res.json({
reconciledSandboxIds: [],
});
});
app.get("/engine/v1/sandboxes/:sandboxId/status", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" });
return;
}
sandbox.lastActiveAt = Date.now();
res.json(
createServiceResponse({
sandboxId: sandbox.id,
status: "running",
previewUrl: getFakeCloudPreviewUrl(sandbox.id),
previewAuthToken: sandbox.previewAuthToken,
previewPort: PORT,
syncRevision: sandbox.syncRevision,
initialSyncCompleted: sandbox.initialSyncCompleted,
appStatus: "running",
syncAgentHealthy: true,
createdAt: new Date(sandbox.createdAt).toISOString(),
lastActiveAt: new Date(sandbox.lastActiveAt).toISOString(),
lastSuccessfulSyncAt: sandbox.lastSuccessfulSyncAt
? new Date(sandbox.lastSuccessfulSyncAt).toISOString()
: null,
expiresAt: new Date(sandbox.lastActiveAt + 10 * 60 * 1000).toISOString(),
billingState: "active",
billingStartedAt: new Date(sandbox.createdAt).toISOString(),
billingLockedAt: null,
lastChargedAt: null,
nextChargeAt: new Date(sandbox.createdAt + 60 * 1000).toISOString(),
billingSlicesCharged: 0,
creditsCharged: 0,
terminationReason: null,
lastErrorCode: null,
lastErrorMessage: null,
}),
);
});
app.post("/engine/v1/sandboxes/:sandboxId/restart", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" });
return;
}
sandbox.lastActiveAt = Date.now();
res.json({
previewUrl: getFakeCloudPreviewUrl(sandbox.id),
previewAuthToken: sandbox.previewAuthToken,
});
});
app.post("/engine/v1/sandboxes/:sandboxId/share-links", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" });
return;
}
const expiresInSeconds =
typeof req.body.expiresInSeconds === "number"
? req.body.expiresInSeconds
: 600;
const shareLinkId = `share-link-${sandbox.id}`;
res.json(
createServiceResponse({
sandboxId: sandbox.id,
shareLinkId,
url: `${getFakeCloudPreviewUrl(sandbox.id)}?share=${shareLinkId}`,
expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString(),
}),
);
});
app.get("/engine/v1/sandboxes/:sandboxId/logs", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const messages = [
"Creating sandbox...",
"Installing dependencies...",
`Starting preview for ${sandbox.id}...`,
];
messages.forEach((message) => {
res.write(`data: ${JSON.stringify({ message })}\n\n`);
});
res.write("data: [DONE]\n\n");
res.end();
});
app.get("/cloud-preview/:sandboxId", (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) {
res.status(404).send("Sandbox not found");
return;
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(getSandboxPreviewHtml(sandbox));
});
// Start the server // Start the server
const server = createServer(app); const server = createServer(app);
server.listen(PORT, () => { server.listen(PORT, () => {
......
...@@ -15,15 +15,18 @@ const path = require("path"); ...@@ -15,15 +15,18 @@ const path = require("path");
const LISTEN_HOST = "localhost"; const LISTEN_HOST = "localhost";
const LISTEN_PORT = workerData.port; const LISTEN_PORT = workerData.port;
let rememberedOrigin = null; // e.g. "http://localhost:5173" let rememberedOrigin = null; // e.g. "http://localhost:5173"
let rememberedBaseUrl = null;
const fixedHeaders = workerData?.fixedHeaders || {};
/* ---------- pre-configure rememberedOrigin from workerData ------- */ /* ---------- pre-configure rememberedOrigin from workerData ------- */
{ {
const fixed = workerData?.targetOrigin; const fixed = workerData?.targetOrigin;
if (fixed) { if (fixed) {
try { try {
rememberedOrigin = new URL(fixed).origin; rememberedBaseUrl = new URL(fixed);
rememberedOrigin = rememberedBaseUrl.origin;
parentPort?.postMessage( parentPort?.postMessage(
`[proxy-worker] fixed upstream: ${rememberedOrigin}`, `[proxy-worker] fixed upstream origin: ${rememberedOrigin}`,
); );
} catch { } catch {
throw new Error( throw new Error(
...@@ -273,10 +276,29 @@ function injectHTML(buf) { ...@@ -273,10 +276,29 @@ function injectHTML(buf) {
/* ---------------- helper: build upstream URL from request -------------- */ /* ---------------- helper: build upstream URL from request -------------- */
function buildTargetURL(clientReq) { function buildTargetURL(clientReq) {
if (!rememberedOrigin) throw new Error("No upstream configured."); if (!rememberedOrigin || !rememberedBaseUrl)
throw new Error("No upstream configured.");
const incomingUrl = new URL(clientReq.url, rememberedOrigin);
const basePath = rememberedBaseUrl.pathname.replace(/\/$/, "");
let incomingPath = incomingUrl.pathname;
if (
basePath &&
(incomingPath === basePath || incomingPath.startsWith(`${basePath}/`))
) {
incomingPath = incomingPath.slice(basePath.length) || "/";
}
const targetPath =
incomingPath === "/"
? rememberedBaseUrl.pathname
: `${basePath}${incomingPath}`;
// Forward to the remembered origin keeping path & query return new URL(
return new URL(clientReq.url, rememberedOrigin); `${targetPath}${incomingUrl.search}`,
rememberedBaseUrl.origin,
);
} }
/* ----------------------------------------------------------------------- */ /* ----------------------------------------------------------------------- */
...@@ -313,7 +335,7 @@ const server = http.createServer((clientReq, clientRes) => { ...@@ -313,7 +335,7 @@ const server = http.createServer((clientReq, clientRes) => {
const lib = isTLS ? https : http; const lib = isTLS ? https : http;
/* Copy request headers but rewrite Host / Origin / Referer */ /* Copy request headers but rewrite Host / Origin / Referer */
const headers = { ...clientReq.headers, host: target.host }; const headers = { ...clientReq.headers, host: target.host, ...fixedHeaders };
if (headers.origin) headers.origin = target.origin; if (headers.origin) headers.origin = target.origin;
if (headers.referer) { if (headers.referer) {
try { try {
...@@ -402,7 +424,7 @@ server.on("upgrade", (req, socket, _head) => { ...@@ -402,7 +424,7 @@ server.on("upgrade", (req, socket, _head) => {
} }
const isTLS = target.protocol === "https:"; const isTLS = target.protocol === "https:";
const headers = { ...req.headers, host: target.host }; const headers = { ...req.headers, host: target.host, ...fixedHeaders };
if (headers.origin) headers.origin = target.origin; if (headers.origin) headers.origin = target.origin;
const upReq = (isTLS ? https : http).request({ const upReq = (isTLS ? https : http).request({
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论