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>
......
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,109 +215,123 @@ export function useStreamChat({ ...@@ -215,109 +215,123 @@ export function useStreamChat({
} }
}, },
onEnd: (response: ChatResponseEnd) => { onEnd: (response: ChatResponseEnd) => {
// Remove from pending set now that stream is complete
pendingStreamChatIds.delete(chatId); pendingStreamChatIds.delete(chatId);
// Only mark as successful if NOT cancelled - wasCancelled flag is set void (async () => {
// by the backend when user cancels the stream // Only mark as successful if NOT cancelled - wasCancelled flag is set
if (response.wasCancelled) { // by the backend when user cancels the stream
setMessagesById((prev) => { if (response.wasCancelled) {
const existingMessages = prev.get(chatId); setMessagesById((prev) => {
if (!existingMessages) return prev; const existingMessages = prev.get(chatId);
if (!existingMessages) return prev;
const updatedMessages = const updatedMessages =
applyCancellationNoticeToLastAssistantMessage( applyCancellationNoticeToLastAssistantMessage(
existingMessages, existingMessages,
); );
if (updatedMessages === existingMessages) { if (updatedMessages === existingMessages) {
return prev; return prev;
} }
const next = new Map(prev); const next = new Map(prev);
next.set(chatId, updatedMessages); next.set(chatId, updatedMessages);
return next; return next;
}); });
} }
if (!response.wasCancelled) { if (!response.wasCancelled) {
setStreamCompletedSuccessfullyById((prev) => { setStreamCompletedSuccessfullyById((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(chatId, true); next.set(chatId, true);
return next; return next;
}); });
} }
// Show native notification if enabled and window is not focused // Show native notification if enabled and window is not focused
// Fire-and-forget to avoid blocking UI updates // Fire-and-forget to avoid blocking UI updates
const notificationsEnabled = const notificationsEnabled =
settings?.enableChatEventNotifications === true; settings?.enableChatEventNotifications === true;
if ( if (
notificationsEnabled && notificationsEnabled &&
Notification.permission === "granted" && Notification.permission === "granted" &&
!document.hasFocus() !document.hasFocus()
) { ) {
const app = queryClient.getQueryData<App | null>( const app = queryClient.getQueryData<App | null>(
queryKeys.apps.detail({ appId: selectedAppId }), queryKeys.apps.detail({ appId: selectedAppId }),
); );
const chats = queryClient.getQueryData<ChatSummary[]>( const chats = queryClient.getQueryData<ChatSummary[]>(
queryKeys.chats.list({ appId: selectedAppId }), queryKeys.chats.list({ appId: selectedAppId }),
); );
const chat = chats?.find((c) => c.id === chatId); const chat = chats?.find((c) => c.id === chatId);
const appName = app?.name ?? "Dyad"; const appName = app?.name ?? "Dyad";
const rawTitle = response.chatSummary ?? chat?.title; const rawTitle = response.chatSummary ?? chat?.title;
const body = rawTitle const body = rawTitle
? rawTitle.length > 80 ? rawTitle.length > 80
? rawTitle.slice(0, 80) + "…" ? rawTitle.slice(0, 80) + "…"
: rawTitle : rawTitle
: "Chat response completed"; : "Chat response completed";
new Notification(appName, { new Notification(appName, {
body, body,
}); });
} }
if (response.updatedFiles) { if (response.updatedFiles) {
if (settings?.autoExpandPreviewPanel) { if (settings?.autoExpandPreviewPanel) {
setIsPreviewOpen(true); setIsPreviewOpen(true);
}
refreshAppIframe();
if (settings?.enableAutoFixProblems) {
checkProblems();
}
} }
refreshAppIframe(); if (response.extraFiles) {
if (settings?.enableAutoFixProblems) { showExtraFilesToast({
checkProblems(); files: response.extraFiles,
error: response.extraFilesError,
posthog,
});
} }
} for (const warningMessage of response.warningMessages ?? []) {
if (response.extraFiles) { showWarning(warningMessage);
showExtraFilesToast({ }
files: response.extraFiles, // Use queryClient directly with the chatId parameter to avoid stale closure issues
error: response.extraFilesError, queryClient.invalidateQueries({
posthog, queryKey: ["proposal", chatId],
}); });
}
for (const warningMessage of response.warningMessages ?? []) {
showWarning(warningMessage);
}
// Use queryClient directly with the chatId parameter to avoid stale closure issues
queryClient.invalidateQueries({ queryKey: ["proposal", chatId] });
refetchUserBudget(); refetchUserBudget();
// Invalidate free agent quota to update the UI after message // Invalidate free agent quota to update the UI after message
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.freeAgentQuota.status, queryKey: queryKeys.freeAgentQuota.status,
}); });
// Keep the same as below // Keep the same as below
setIsStreamingById((prev) => { setIsStreamingById((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(chatId, false); next.set(chatId, false);
return next; return next;
}); });
// 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({ queryClient.invalidateQueries({
queryKey: queryKeys.proposals.detail({ chatId }), queryKey: queryKeys.proposals.detail({ chatId }),
});
invalidateChats();
refreshApp();
refreshVersions();
invalidateTokenCount();
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 });
}); });
invalidateChats();
refreshApp();
refreshVersions();
invalidateTokenCount();
onSettled?.({ success: true });
}, },
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(
......
...@@ -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 { 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}`;
} }
......
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论