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 {
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() {
await this.page
.getByTestId("preview-annotator-button")
......@@ -169,9 +177,9 @@ export class PreviewPanel {
return this.page.getByTestId("preview-iframe-element");
}
expectPreviewIframeIsVisible() {
expectPreviewIframeIsVisible(timeout = Timeout.LONG) {
return expect(this.getPreviewIframeElement()).toBeVisible({
timeout: Timeout.LONG,
timeout,
});
}
......
......@@ -36,6 +36,12 @@ export class Settings {
.click();
}
async toggleCloudSandboxExperiment() {
await this.page
.getByRole("switch", { name: "Enable Cloud Sandbox" })
.click();
}
async toggleEnableSelectAppFromHomeChatInput() {
await this.page
.getByRole("switch", {
......@@ -55,6 +61,20 @@ export class Settings {
.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() {
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
**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
### Handling unstaged changes during rebase
......
......@@ -209,6 +209,8 @@ function TitleBarActions() {
const { t } = useTranslation("home");
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { restartApp, refreshAppIframe } = useRunApp();
const { settings } = useSettings();
const isCloudSandboxMode = settings?.runtimeMode2 === "cloud";
const onCleanRestart = useCallback(() => {
restartApp({ removeNodeModules: true });
......@@ -235,6 +237,10 @@ function TitleBarActions() {
clearSessionData();
}, [clearSessionData]);
const onRecreateSandbox = useCallback(() => {
restartApp({ recreateSandbox: true });
}, [restartApp]);
return (
<div
className="flex items-center gap-0.5 no-app-region-drag mr-2"
......@@ -266,6 +272,17 @@ function TitleBarActions() {
</span>
</div>
</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>
</DropdownMenu>
</div>
......
import { atom } from "jotai";
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 selectedAppIdAtom = atom<number | null>(null);
......@@ -18,9 +18,19 @@ export const selectedVersionIdAtom = atom<string | null>(null);
export const appConsoleEntriesAtom = atom<ConsoleEntry[]>([]);
export const appUrlAtom = atom<
| { appUrl: string; appId: number; originalUrl: string }
| { appUrl: null; appId: null; originalUrl: null }
>({ appUrl: null, appId: null, originalUrl: null });
| {
appUrl: string;
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);
// Atom for storing allow-listed environment variables
......@@ -33,5 +43,6 @@ export const previewPanelKeyAtom = atom<number>(0);
export const previewCurrentUrlAtom = atom<Record<number, string>>({});
export const previewErrorMessageAtom = atom<
{ message: string; source: "preview-app" | "dyad-app" } | undefined
| { message: string; source: "preview-app" | "dyad-app" | "dyad-sync" }
| 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 {
SelectValue,
} from "@/components/ui/select";
import { useSettings } from "@/hooks/useSettings";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { showError } from "@/lib/toast";
import { ipc } from "@/ipc/types";
import { useAtomValue } from "jotai";
import { appUrlAtom } from "@/atoms/appAtoms";
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() {
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) {
return null;
}
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 {
await updateSettings({ runtimeMode2: value });
} catch (error: any) {
......@@ -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 (
<div className="space-y-2">
<div className="space-y-1">
......@@ -46,6 +99,11 @@ export function RuntimeModeSelector() {
<SelectContent>
<SelectItem value="host">Local (default)</SelectItem>
<SelectItem value="docker">Docker (experimental)</SelectItem>
{showCloudSandboxOption && (
<SelectItem disabled={!hasCloudSandboxAccess} value="cloud">
Cloud Sandbox (Pro)
</SelectItem>
)}
</SelectContent>
</Select>
</div>
......@@ -53,6 +111,18 @@ export function RuntimeModeSelector() {
{t("general.runtimeModeDescription")}
</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 && (
<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{" "}
......@@ -70,6 +140,45 @@ export function RuntimeModeSelector() {
to be installed and running
</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>
);
}
......@@ -99,13 +99,26 @@ function FooterComponent({ context }: { context?: FooterContext }) {
const currentMessage = messages[messages.length - 1];
// The user message that triggered this assistant response
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(
"Reverting to source commit hash",
currentMessage.sourceCommitHash,
"Reverting to previous version",
revertTargetVersionId,
);
await revertVersion({
versionId: currentMessage.sourceCommitHash,
versionId: revertTargetVersionId,
currentChatMessageId: userMessage
? {
chatId: selectedChatId,
......
......@@ -17,6 +17,7 @@ import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRunApp } from "@/hooks/useRunApp";
import { useSettings } from "@/hooks/useSettings";
import {
DropdownMenu,
DropdownMenuContent,
......@@ -58,6 +59,8 @@ export const ActionHeader = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const { problemReport } = useCheckProblems(selectedAppId);
const { restartApp, refreshAppIframe } = useRunApp();
const { settings } = useSettings();
const isCloudSandboxMode = settings?.runtimeMode2 === "cloud";
const isCompact = windowWidth < 888;
......@@ -105,6 +108,10 @@ export const ActionHeader = () => {
clearSessionData();
}, [clearSessionData]);
const onRecreateSandbox = useCallback(() => {
restartApp({ recreateSandbox: true });
}, [restartApp]);
// Get the problem count for the selected app
const problemCount = problemReport ? problemReport.problems.length : 0;
......@@ -297,6 +304,17 @@ export const ActionHeader = () => {
</span>
</div>
</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>
</DropdownMenu>
</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";
import { activeCheckoutCounterAtom } from "@/store/appAtoms";
import { queryKeys } from "@/lib/queryKeys";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { useRunApp } from "./useRunApp";
import { useSettings } from "./useSettings";
interface CheckoutVersionVariables {
appId: number;
......@@ -13,6 +15,8 @@ interface CheckoutVersionVariables {
export function useCheckoutVersion() {
const queryClient = useQueryClient();
const setActiveCheckouts = useSetAtom(activeCheckoutCounterAtom);
const { restartApp } = useRunApp();
const { settings } = useSettings();
const { isPending: isCheckingOutVersion, mutateAsync: checkoutVersion } =
useMutation<void, Error, CheckoutVersionVariables>({
......@@ -31,14 +35,17 @@ export function useCheckoutVersion() {
setActiveCheckouts((prev) => prev - 1); // Decrement counter
}
},
onSuccess: (_, variables) => {
onSuccess: async (_, variables) => {
// Invalidate queries that depend on the current version/branch
queryClient.invalidateQueries({
await queryClient.invalidateQueries({
queryKey: queryKeys.branches.current({ appId: variables.appId }),
});
queryClient.invalidateQueries({
await queryClient.invalidateQueries({
queryKey: queryKeys.versions.list({ appId: variables.appId }),
});
if (settings?.runtimeMode2 === "cloud") {
await restartApp();
}
},
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 { ipc, type AppOutput } from "@/ipc/types";
import {
......@@ -11,9 +11,11 @@ import {
selectedAppIdAtom,
} from "@/atoms/appAtoms";
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 CLOUD_SYNC_ERROR_TOAST_WINDOW_MS = 30_000;
/**
* Hook to subscribe to app output events from the main process.
......@@ -23,8 +25,12 @@ const useRunAppLoadingAtom = atom(false);
export function useAppOutputSubscription() {
const setConsoleEntries = useSetAtom(appConsoleEntriesAtom);
const [, setAppUrlObj] = useAtom(appUrlAtom);
const [, setPreviewErrorMessage] = useAtom(previewErrorMessageAtom);
const setPreviewPanelKey = useSetAtom(previewPanelKeyAtom);
const appId = useAtomValue(selectedAppIdAtom);
const syncErrorToastRef = useRef(
new Map<number, { message: string; shownAt: number }>(),
);
const processProxyServerOutput = useCallback(
(output: AppOutput) => {
......@@ -37,14 +43,17 @@ export function useAppOutputSubscription() {
/\[dyad-proxy-server\]started=\[(.*?)\]/,
);
const originalUrlMatch = output.message.match(/original=\[(.*?)\]/);
const modeMatch = output.message.match(/mode=\[(.*?)\]/);
if (proxyUrlMatch && proxyUrlMatch[1]) {
const proxyUrl = proxyUrlMatch[1];
const originalUrl = originalUrlMatch && originalUrlMatch[1];
const mode = (modeMatch?.[1] as RuntimeMode2 | undefined) ?? "host";
setAppUrlObj({
appUrl: proxyUrl,
appId: output.appId,
originalUrl: originalUrl!,
mode,
});
}
}
......@@ -73,6 +82,39 @@ export function useAppOutputSubscription() {
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
if (
output.message.includes("hmr update") &&
......@@ -88,7 +130,9 @@ export function useAppOutputSubscription() {
// Server logs (stdout/stderr) are already stored in the main process
const logEntry = {
level:
output.type === "stderr" || output.type === "client-error"
output.type === "stderr" ||
output.type === "client-error" ||
output.type === "sync-error"
? ("error" as const)
: ("info" as const),
type: "server" as const,
......@@ -103,7 +147,7 @@ export function useAppOutputSubscription() {
return logEntry;
},
[processProxyServerOutput, onHotModuleReload],
[onHotModuleReload, processProxyServerOutput, setPreviewErrorMessage],
);
// Subscribe to immediate app output events (input-requested)
......@@ -163,7 +207,7 @@ export function useRunApp() {
// Clear the URL and add restart message
setAppUrlObj((prevAppUrlObj) => {
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
});
......@@ -228,7 +272,8 @@ export function useRunApp() {
const restartApp = useCallback(
async ({
removeNodeModules = false,
}: { removeNodeModules?: boolean } = {}) => {
recreateSandbox = false,
}: { removeNodeModules?: boolean; recreateSandbox?: boolean } = {}) => {
if (appId === null) {
return;
}
......@@ -237,11 +282,17 @@ export function useRunApp() {
console.debug(
"Restarting app",
appId,
recreateSandbox ? "with sandbox recreation" : "",
removeNodeModules ? "with node_modules cleanup" : "",
);
// 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
setPreservedUrls((prev) => {
......@@ -270,7 +321,7 @@ export function useRunApp() {
const app = await ipc.app.getApp(appId);
setApp(app);
await ipc.app.restartApp({ appId, removeNodeModules });
await ipc.app.restartApp({ appId, removeNodeModules, recreateSandbox });
} catch (error) {
console.error(`Error restarting app ${appId}:`, error);
setPreviewErrorMessage(
......
......@@ -215,8 +215,8 @@ export function useStreamChat({
}
},
onEnd: (response: ChatResponseEnd) => {
// Remove from pending set now that stream is complete
pendingStreamChatIds.delete(chatId);
void (async () => {
// Only mark as successful if NOT cancelled - wasCancelled flag is set
// by the backend when user cancels the stream
if (response.wasCancelled) {
......@@ -294,7 +294,9 @@ export function useStreamChat({
showWarning(warningMessage);
}
// Use queryClient directly with the chatId parameter to avoid stale closure issues
queryClient.invalidateQueries({ queryKey: ["proposal", chatId] });
queryClient.invalidateQueries({
queryKey: ["proposal", chatId],
});
refetchUserBudget();
......@@ -318,6 +320,18 @@ export function useStreamChat({
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 });
});
},
onError: ({ error: errorMessage, warningMessages }) => {
// Remove from pending set now that stream ended with error
......
......@@ -8,12 +8,16 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { toast } from "sonner";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { useRunApp } from "./useRunApp";
import { useSettings } from "./useSettings";
export function useVersions(appId: number | null) {
const [, setVersionsAtom] = useAtom(versionsListAtom);
const selectedChatId = useAtomValue(selectedChatIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const queryClient = useQueryClient();
const { restartApp } = useRunApp();
const { settings } = useSettings();
const {
data: versions,
......@@ -87,6 +91,9 @@ export function useVersions(appId: number | null) {
await queryClient.invalidateQueries({
queryKey: queryKeys.problems.byApp({ appId }),
});
if (settings?.runtimeMode2 === "cloud") {
await restartApp();
}
},
meta: { showErrorToast: true },
});
......
......@@ -205,6 +205,8 @@
"rebuildDescription": "Re-installs node_modules and restarts",
"clearCache": "Clear 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...",
"noAppSelected": "No app selected",
"refreshFiles": "Refresh Files",
......
......@@ -21,6 +21,9 @@
"selectReleaseChannel": "Select release channel",
"runtimeMode": "Runtime Mode",
"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",
"nodePath": "Node.js Path Configuration",
"browseForNode": "Browse for Node.js",
......
......@@ -202,6 +202,8 @@
"rebuildDescription": "Reinstala o node_modules e reinicia",
"clearCache": "Limpar Cache",
"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...",
"noAppSelected": "Nenhum app selecionado",
"refreshFiles": "Atualizar Arquivos",
......
......@@ -21,6 +21,9 @@
"selectReleaseChannel": "Selecionar canal de lançamento",
"runtimeMode": "Modo de Execução",
"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",
"nodePath": "Configuração do Caminho do Node.js",
"browseForNode": "Procurar Node.js",
......
......@@ -202,6 +202,8 @@
"rebuildDescription": "重新安装 node_modules 并重启",
"clearCache": "清除缓存",
"clearCacheDescription": "清除 Cookie、本地存储及其他应用缓存",
"recreateSandbox": "重新创建沙箱",
"recreateSandboxDescription": "销毁当前沙箱并创建一个新的沙箱",
"loadingFiles": "正在加载文件...",
"noAppSelected": "未选择应用",
"refreshFiles": "刷新文件",
......
......@@ -21,6 +21,9 @@
"selectReleaseChannel": "选择发布通道",
"runtimeMode": "运行时模式",
"runtimeModeDescription": "选择用于运行应用的运行时。",
"runtimeModeSwitchTitle": "切换运行时模式?",
"runtimeModeSwitchDescription": "切换运行时模式会停止当前正在运行的应用。",
"runtimeModeSwitchAction": "切换运行时",
"selectRuntimeMode": "选择运行时模式",
"nodePath": "Node.js 路径配置",
"browseForNode": "浏览 Node.js",
......
......@@ -13,6 +13,7 @@ import {
parseEnvFile,
serializeEnvFile,
} from "../utils/app_env_var_utils";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
import { createTypedHandler } from "./base";
import { miscContracts } from "../types/misc";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
......@@ -72,6 +73,10 @@ export function registerAppEnvVarsHandlers() {
// Write to .env.local file
await fs.promises.writeFile(envFilePath, content, "utf8");
queueCloudSandboxSnapshotSync({
appId,
changedPaths: [ENV_FILE_NAME],
});
} catch (error) {
console.error("Error setting app environment variables:", error);
throw new Error(
......
......@@ -33,9 +33,21 @@ import {
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { retryOnLocked } from "../utils/retryOnLocked";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { syncCloudSandboxSnapshot } from "../utils/cloud_sandbox_provider";
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({
appId,
dbTimestamp,
......@@ -365,6 +377,7 @@ export function registerVersionHandlers() {
// Continue with the revert operation even if function deployment fails
}
}
await syncCloudSandboxSnapshotBestEffort(appId);
if (warningMessage) {
return { warningMessage };
}
......@@ -449,6 +462,7 @@ export function registerVersionHandlers() {
path: fullAppPath,
ref: gitRef,
});
await syncCloudSandboxSnapshotBestEffort(appId);
});
});
}
......
......@@ -5,16 +5,25 @@ import {
} from "@/ipc/utils/socket_firewall";
import { ExecuteAddDependencyError } from "./executeAddDependency";
const { executeAddDependencyMock, readSettingsMock } = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
executeAddDependencyMock: vi.fn(),
queueCloudSandboxSnapshotSyncMock: vi.fn(),
readSettingsMock: vi.fn(),
}));
const {
executeAddDependencyMock,
queueCloudSandboxSnapshotSyncMock,
readSettingsMock,
} = mocks;
const dbUpdates: Array<Record<string, unknown>> = [];
vi.mock("node:fs", async () => ({
default: {
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
promises: {
readFile: vi.fn().mockResolvedValue(""),
},
......@@ -56,7 +65,11 @@ vi.mock("../utils/git_utils", () => ({
}));
vi.mock("@/main/settings", () => ({
readSettings: readSettingsMock,
readSettings: mocks.readSettingsMock,
}));
vi.mock("../utils/cloud_sandbox_provider", () => ({
queueCloudSandboxSnapshotSync: mocks.queueCloudSandboxSnapshotSyncMock,
}));
vi.mock("./executeAddDependency", async () => {
......@@ -66,12 +79,12 @@ vi.mock("./executeAddDependency", async () => {
return {
...actual,
executeAddDependency: executeAddDependencyMock,
executeAddDependency: mocks.executeAddDependencyMock,
};
});
import { db } from "../../db";
import { gitAdd } from "../utils/git_utils";
import { gitAdd, hasStagedChanges } from "../utils/git_utils";
import { processFullResponseActions } from "./response_processor";
describe("processFullResponseActions add dependency errors", () => {
......@@ -156,4 +169,29 @@ describe("processFullResponseActions add dependency errors", () => {
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
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { executeCopyFile } from "../utils/copy_file_utils";
import { escapeXmlAttr, escapeXmlContent } from "../../../shared/xmlEscape";
import { queueCloudSandboxSnapshotSync } from "../utils/cloud_sandbox_provider";
const readFile = fs.promises.readFile;
const logger = log.scope("response_processor");
......@@ -647,6 +648,17 @@ export async function processFullResponseActions(
})
.where(eq(messages.id, messageId));
if (hasChanges) {
queueCloudSandboxSnapshotSync({
appId: chatWithApp.app.id,
changedPaths: [...writtenFiles, ...renamedFiles],
deletedPaths: [
...dyadDeletePaths,
...dyadRenameTags.map((renameTag) => renameTag.from),
],
});
}
return {
updatedFiles: hasChanges,
extraFiles: uncommittedFiles.length > 0 ? uncommittedFiles : undefined,
......
......@@ -112,6 +112,58 @@ export const AppIdParamsSchema = z.object({
export const RestartAppParamsSchema = z.object({
appId: z.number(),
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 = {
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({
channel: "edit-app-file",
input: EditAppFileParamsSchema,
......@@ -431,3 +495,4 @@ export type AppSearchResult = z.infer<typeof AppSearchResultSchema>;
export type UpdateAppCommandsParams = z.infer<
typeof UpdateAppCommandsParamsSchema
>;
export type CloudSandboxStatus = z.infer<typeof CloudSandboxStatusSchema>;
......@@ -295,7 +295,15 @@ export type { DeepLinkData } from "../deep_link_data";
// =============================================================================
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(),
appId: z.number(),
timestamp: z.number().optional(),
......
......@@ -9,6 +9,7 @@ import path from "path";
import fs from "fs";
import log from "electron-log";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "./cloud_sandbox_provider";
const logger = log.scope("app_env_var_utils");
......@@ -41,6 +42,10 @@ export async function updatePostgresUrlEnvVar({
const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
queueCloudSandboxSnapshotSync({
appPath: getDyadAppPath(appPath),
changedPaths: [ENV_FILE_NAME],
});
}
export async function updateDbPushEnvVar({
......@@ -76,6 +81,10 @@ export async function updateDbPushEnvVar({
const envFileContents = serializeEnvFile(envVars);
await fs.promises.writeFile(getEnvFilePath({ appPath }), envFileContents);
queueCloudSandboxSnapshotSync({
appPath: getDyadAppPath(appPath),
changedPaths: [ENV_FILE_NAME],
});
} catch (error) {
logger.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";
import treeKill from "tree-kill";
import log from "electron-log";
import type { Worker } from "node:worker_threads";
import type { RuntimeMode2 } from "@/lib/schemas";
import { withLock } from "./lock_utils";
import {
destroyCloudSandbox,
stopCloudSandboxFileSync,
unregisterRunningCloudSandbox,
} from "./cloud_sandbox_provider";
const logger = log.scope("process_manager");
// Define a type for the value stored in runningApps
export interface RunningAppInfo {
process: ChildProcess;
process: ChildProcess | null;
processId: number;
isDocker: boolean;
mode: RuntimeMode2;
rendererSender?: Electron.WebContents;
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 */
lastViewedAt: number;
/** Proxy URL for the running app, set when the proxy server starts */
......@@ -128,17 +141,27 @@ export async function stopAppByInfo(
appId: number,
appInfo: RunningAppInfo,
): Promise<void> {
if (appInfo.proxyWorker) {
await appInfo.proxyWorker.terminate();
appInfo.proxyWorker = undefined;
}
stopCloudSandboxFileSync(appId);
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}`;
await stopDockerContainer(containerName);
} else {
} else if (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);
}
......@@ -157,6 +180,10 @@ export function removeAppIfCurrentProcess(
void currentAppInfo.proxyWorker.terminate();
currentAppInfo.proxyWorker = undefined;
}
currentAppInfo.cloudLogAbortController?.abort();
currentAppInfo.cloudLogAbortController = undefined;
stopCloudSandboxFileSync(appId);
unregisterRunningCloudSandbox({ appId });
runningApps.delete(appId);
logger.info(
`Removed app ${appId} (processId ${currentAppInfo.processId}) from running map. Current size: ${runningApps.size}`,
......@@ -334,7 +361,22 @@ export function stopAllAppsSync(): void {
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}`;
// Fire-and-forget: spawn docker stop without awaiting
const stop = spawn("docker", ["stop", containerName], {
......@@ -346,16 +388,17 @@ export function stopAllAppsSync(): void {
);
});
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(appInfo.process.pid, "SIGTERM", (err: Error | undefined) => {
treeKill(pid, "SIGTERM", (err: Error | undefined) => {
if (err) {
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);
}
......
......@@ -15,6 +15,7 @@ export async function startProxy(
// port?: number;
// env?: Record<string, string>;
onStarted?: (proxyUrl: string) => void;
fixedHeaders?: Record<string, string>;
} = {},
) {
if (!/^https?:\/\//.test(targetOrigin))
......@@ -28,6 +29,7 @@ export async function startProxy(
// host = "localhost",
// env = {}, // additional env vars to pass to the worker
onStarted,
fixedHeaders,
} = opts;
const worker = new Worker(
......@@ -36,6 +38,7 @@ export async function startProxy(
workerData: {
targetOrigin,
port,
fixedHeaders,
},
},
);
......
......@@ -225,6 +225,11 @@ export const queryKeys = {
info: ["userBudgetInfo"] as const,
},
cloudSandboxes: {
status: ({ appId }: { appId: number | null }) =>
["cloudSandboxStatus", appId] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
// Free Agent Quota
// ─────────────────────────────────────────────────────────────────────────────
......@@ -360,6 +365,9 @@ export type AppQueryKey =
(typeof queryKeys.languageModels)[keyof typeof queryKeys.languageModels]
>
| QueryKeyOf<(typeof queryKeys.userBudget)[keyof typeof queryKeys.userBudget]>
| QueryKeyOf<
(typeof queryKeys.cloudSandboxes)[keyof typeof queryKeys.cloudSandboxes]
>
| QueryKeyOf<
(typeof queryKeys.freeAgentQuota)[keyof typeof queryKeys.freeAgentQuota]
>
......
......@@ -141,7 +141,7 @@ export type VertexProviderSetting = z.infer<typeof VertexProviderSettingSchema>;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
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>;
/**
......@@ -213,6 +213,7 @@ export const ExperimentsSchema = z.object({
enableLocalAgent: z.boolean().describe("DEPRECATED").optional(),
enableSupabaseIntegration: z.boolean().describe("DEPRECATED").optional(),
enableFileEditing: z.boolean().describe("DEPRECATED").optional(),
enableCloudSandbox: z.boolean().optional(),
});
export type Experiments = z.infer<typeof ExperimentsSchema>;
......
......@@ -6,6 +6,30 @@ import {
} from "./settingsSearchIndex";
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", () => {
expect(
SETTINGS_SEARCH_INDEX.find(
......
......@@ -34,6 +34,7 @@ export const SETTING_IDS = {
supabase: "setting-supabase",
neon: "setting-neon",
nativeGit: "setting-native-git",
enableCloudSandbox: "setting-enable-cloud-sandbox",
blockUnsafeNpmPackages: "setting-block-unsafe-npm-packages",
enableMcpServersForBuildMode: "setting-enable-mcp-servers-for-build-mode",
enableSelectAppFromHomeChatInput:
......@@ -342,6 +343,23 @@ export const SETTINGS_SEARCH_INDEX: SearchableSettingItem[] = [
sectionId: SECTION_IDS.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,
label: "Block unsafe npm packages",
......
......@@ -35,6 +35,7 @@ import { LanguageSelector } from "@/components/LanguageSelector";
import { DefaultChatModeSelector } from "@/components/DefaultChatModeSelector";
import { ContextCompactionSwitch } from "@/components/ContextCompactionSwitch";
import { BlockUnsafeNpmPackagesSwitch } from "@/components/BlockUnsafeNpmPackagesSwitch";
import { CloudSandboxExperimentSwitch } from "@/components/CloudSandboxExperimentSwitch";
import { useSetAtom } from "jotai";
import { activeSettingsSectionAtom } from "@/atoms/viewAtoms";
import { SECTION_IDS, SETTING_IDS } from "@/lib/settingsSearchIndex";
......@@ -196,6 +197,12 @@ export default function SettingsPage() {
a faster, native-Git performance experience.
</div>
</div>
<div
id={SETTING_IDS.enableCloudSandbox}
className="space-y-1 mt-4"
>
<CloudSandboxExperimentSwitch />
</div>
<div
id={SETTING_IDS.blockUnsafeNpmPackages}
className="space-y-1 mt-4"
......
import { z } from "zod";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import { executeCopyFile } from "@/ipc/utils/copy_file_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const copyFileSchema = z.object({
from: z
......@@ -45,6 +46,11 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = {
ctx.isSharedModulesChanged = true;
}
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.to],
});
if (result.deployError) {
return `File copied, but failed to deploy Supabase function: ${result.deployError}`;
}
......
......@@ -11,6 +11,7 @@ import {
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("delete_file");
......@@ -95,6 +96,11 @@ export const deleteFileTool: ToolDefinition<z.infer<typeof deleteFileSchema>> =
logger.warn(`File to delete does not exist: ${fullFilePath}`);
}
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
deletedPaths: [args.path],
});
return `Successfully deleted ${args.path}`;
},
};
......@@ -11,6 +11,7 @@ import {
} from "../../../../../../supabase_admin/supabase_utils";
import { engineFetch } from "./engine_fetch";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const readFile = fs.promises.readFile;
const logger = log.scope("edit_file");
......@@ -200,6 +201,10 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
// Write file content
fs.writeFileSync(fullFilePath, newContent);
logger.log(`Successfully edited file: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.path],
});
// Deploy Supabase function if applicable
if (
......
......@@ -13,6 +13,7 @@ import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("rename_file");
......@@ -100,6 +101,12 @@ export const renameFileTool: ToolDefinition<z.infer<typeof renameFileSchema>> =
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}`;
},
};
......@@ -18,6 +18,7 @@ import {
} from "@/supabase_admin/supabase_utils";
import { sendTelemetryEvent } from "@/ipc/utils/telemetry";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("search_replace");
......@@ -133,6 +134,10 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
await fs.promises.writeFile(fullFilePath, result.content);
logger.log(`Successfully applied search-replace to: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.file_path],
});
sendTelemetryEvent("local_agent:search_replace:success", {
filePath: args.file_path,
});
......
......@@ -9,6 +9,7 @@ import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { queueCloudSandboxSnapshotSync } from "@/ipc/utils/cloud_sandbox_provider";
const logger = log.scope("write_file");
const writeFileSchema = z.object({
......@@ -54,6 +55,10 @@ export const writeFileTool: ToolDefinition<z.infer<typeof writeFileSchema>> = {
// Write file content
fs.writeFileSync(fullFilePath, args.content);
logger.log(`Successfully wrote file: ${fullFilePath}`);
queueCloudSandboxSnapshotSync({
appId: ctx.appId,
changedPaths: [args.path],
});
// Deploy Supabase function if applicable
if (
......
......@@ -29,6 +29,7 @@ import {
} from "../../utils/visual_editing_utils";
import { normalizePath } from "../../../../../shared/normalizePath";
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
const MAX_IMAGE_SIZE = Math.ceil((7.5 * 1024 * 1024) / 3) * 4 + 100; // ~10,485,860
......@@ -167,6 +168,8 @@ export function registerVisualEditingHandlers() {
});
}
const changedPaths = new Set<string>();
// Apply changes to each file
for (const [relativePath, lineChanges] of fileChanges) {
const normalizedRelativePath = normalizePath(relativePath);
......@@ -174,6 +177,7 @@ export function registerVisualEditingHandlers() {
const content = await fsPromises.readFile(filePath, "utf-8");
const transformedContent = transformContent(content, lineChanges);
await fsPromises.writeFile(filePath, transformedContent, "utf-8");
changedPaths.add(normalizedRelativePath);
// Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) {
await gitAdd({
......@@ -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) {
// Unstage any image files that were git-added before the failure
for (const { appPath, filepath } of stagedGitPaths) {
......
import express from "express";
import { createServer } from "http";
import cors from "cors";
import crypto from "node:crypto";
import { createChatCompletionHandler } from "./chatCompletionHandler";
import { createResponsesHandler } from "./responsesHandler";
import {
......@@ -74,6 +75,83 @@ export const CANNED_MESSAGE = `
More
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) => {
res.send("OK");
});
......@@ -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
const server = createServer(app);
server.listen(PORT, () => {
......
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论