Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/api/src/tools/sandbox/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,13 +487,22 @@ async function provisionSandbox(
// runners free to assign a unique dynamic port (user-desktop needs this;
// multiple sandboxes on the user's machine share the host network and
// can't all bind 3000).
// Live production URL (already normalized to an https URL by the settings
// form). Forwarded so the daemon's Fast Preview route can render the draft
// against production. Harmless when Fast Preview is off — the daemon only
// reads it when that route is hit.
const productionUrl =
typeof metadata.productionUrl === "string" && metadata.productionUrl.trim()
? metadata.productionUrl.trim()
: undefined;
const workload: Workload | undefined =
runtime && packageManager
? {
runtime,
packageManager,
...(port !== null ? { devPort: Number(port) } : {}),
...(packageManagerPath ? { packageManagerPath } : {}),
...(productionUrl ? { productionUrl } : {}),
}
: undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildDirectDaemonEventsUrl,
isDirectDaemonEventsGoneStatus,
isLiveMetaKeyForScope,
isWorkingTreeReadyPhase,
} from "./sandbox-events-context";

describe("buildDirectDaemonEventsUrl", () => {
Expand Down Expand Up @@ -67,3 +68,33 @@ describe("isLiveMetaKeyForScope", () => {
);
});
});

describe("isWorkingTreeReadyPhase", () => {
test("false before the repo is on disk", () => {
// The cold-start window that strands Fast Preview: the daemon answers, but
// `.deco/blocks/` doesn't exist, so /read 400s and useDecofile 502s.
expect(isWorkingTreeReadyPhase("idle")).toBe(false);
expect(isWorkingTreeReadyPhase("cloning")).toBe(false);
expect(isWorkingTreeReadyPhase("clone-failed")).toBe(false);
});

test("checking-out is excluded — the tree is mid-write", () => {
expect(isWorkingTreeReadyPhase("checking-out")).toBe(false);
});

test("installing is the trigger — clone done, deps pending", () => {
// Precisely the window Fast Preview exists to render in.
expect(isWorkingTreeReadyPhase("installing")).toBe(true);
});

test("later phases also count — a warm sandbox can skip past installing", () => {
expect(isWorkingTreeReadyPhase("starting")).toBe(true);
expect(isWorkingTreeReadyPhase("running")).toBe(true);
});

test("failure phases count — a failed install still leaves a readable tree", () => {
expect(isWorkingTreeReadyPhase("install-failed")).toBe(true);
expect(isWorkingTreeReadyPhase("start-failed")).toBe(true);
expect(isWorkingTreeReadyPhase("crashed")).toBe(true);
});
});
109 changes: 88 additions & 21 deletions apps/web/src/components/sandbox/hooks/sandbox-events-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
type ReactNode,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useProjectContext } from "@/sdk";
import { useProjectContext, useVirtualMCP } from "@/sdk";
import { sanitizeProductionUrl } from "@decocms/shared/deco-site-production-url";
import { KEYS, invalidateVirtualMcpQueries } from "@/lib/query-keys";
import { exponentialBackoffWithJitter } from "@decocms/shared/std";

Expand All @@ -48,6 +49,10 @@ import type {
DaemonStatus,
LifecycleState,
} from "@decocms/sandbox/shared";
// One definition, shared with the daemon: it decides when to announce a draft
// version, Studio decides when to re-drive the CMS queries. They must agree.
export { isWorkingTreeReadyPhase } from "@decocms/sandbox/shared";
import { isWorkingTreeReadyPhase } from "@decocms/sandbox/shared";

export type { BranchMeta, DaemonStatus, LifecycleState };

Expand All @@ -71,6 +76,14 @@ export interface SandboxEventsValue {
branch: BranchMeta;
/** True after a `gone` event — handle gone, reprovision via SANDBOX_START. */
notFound: boolean;
/**
* Content version of the working-tree draft decofile, or null until the
* daemon first announces one. Same value `/_sandbox/decofile` serves as its
* ETag, so it can be used verbatim in a draft pointer. Changes on every
* `.deco/blocks/*` save, which is what lets the preview refresh without
* polling.
*/
decofileVersion: string | null;
scripts: string[];
activeProcesses: string[];
getBuffer: (source: string) => string;
Expand All @@ -92,6 +105,7 @@ const DEFAULT_VALUE: SandboxEventsValue = {
status: { state: "running" },
branch: { kind: "unknown" },
notFound: false,
decofileVersion: null,
scripts: [],
activeProcesses: [],
getBuffer: () => "",
Expand Down Expand Up @@ -134,6 +148,7 @@ const DAEMON_EVENT_TYPES: readonly DaemonEventName[] = [
"branch",
"reload",
"file-changed",
"decofile",
] as const;
// `log` is broadcast separately — same SSE stream, different shape.
const LOG_EVENT = "log" as const;
Expand Down Expand Up @@ -205,8 +220,24 @@ export function SandboxEventsProvider({
}) {
const { org } = useProjectContext();
const queryClient = useQueryClient();

// Fast Preview renders via the daemon with NO dev server, so the
// dev-server-gated `.deco/*` reload path below would never fire. Track it (as
// a ref, so the SSE closure reads the latest without reconnecting) to still
// fire a reload on a Blocks save. We deliberately do NOT invalidate the
// decofile query in that mode — the daemon reads `.deco/blocks/*` straight
// from disk, and a refetch could revert an optimistic edit against a committed
// `blocks.gen.json`.
const vmcp = useVirtualMCP(virtualMcpId ?? undefined);
const fastPreviewActive =
!!sanitizeProductionUrl(vmcp?.metadata?.productionUrl) &&
vmcp?.metadata?.fastPreview === true;
const fastPreviewActiveRef = useRef(fastPreviewActive);
// oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- keep the SSE closure reading the latest value without reconnecting
fastPreviewActiveRef.current = fastPreviewActive;
const [phase, setPhase] = useState<ClaimPhase | null>(null);
const [lifecycle, setLifecycle] = useState<LifecycleState>({ phase: "idle" });
const [decofileVersion, setDecofileVersion] = useState<string | null>(null);
const [status, setStatus] = useState<DaemonStatus>({ state: "running" });
const [branchMeta, setBranchMeta] = useState<BranchMeta>({ kind: "unknown" });
const [notFound, setNotFound] = useState(false);
Expand Down Expand Up @@ -241,6 +272,7 @@ export function SandboxEventsProvider({
setBranchMeta({ kind: "unknown" });
prevPortRef.current = null;
setNotFound(false);
setDecofileVersion(null);
setScripts([]);
setActiveProcesses([]);
buffers.current.clear();
Expand All @@ -259,7 +291,7 @@ export function SandboxEventsProvider({
// Tracks the dev-server lifecycle so we can invalidate the CMS queries once
// it comes up (switching them from the committed `.deco/*.gen.json` snapshot
// to the live `/.decofile` + `/live/_meta` routes).
let prevLifecyclePhase: string | null = null;
let prevLifecyclePhase: LifecycleState["phase"] | null = null;

const handleClaimPhase = (e: MessageEvent) => {
try {
Expand Down Expand Up @@ -327,6 +359,30 @@ export function SandboxEventsProvider({
case "lifecycle": {
const lp = payload as DaemonEventPayload<"lifecycle">;
setLifecycle(lp.state);
const cacheKeyForBranch = `${org.slug}/${virtualMcpId}/${branch}`;
// Working tree just landed (see isWorkingTreeReadyPhase). Re-drive
// the two things Fast Preview needs but that nothing else retries:
// the decofile — whose cold-start read 502s while the repo is still
// cloning and is never retried — and the entity, which carries
// `previewUrl` in `sandboxMap` behind a 60s staleTime. Without this
// both stay stale until `running` below, stranding Fast Preview
// behind the very install it exists to skip.
if (
isWorkingTreeReadyPhase(lp.state.phase) &&
!(
prevLifecyclePhase &&
isWorkingTreeReadyPhase(prevLifecyclePhase)
)
) {
invalidateVirtualMcpQueries(queryClient, org.id);
// Only when the decofile never loaded (the 502 case) — a
// populated cache can hold an optimistic block edit a refetch
// would revert, the same hazard the post-write path guards.
const decofileKey = KEYS.decofile(cacheKeyForBranch);
if (queryClient.getQueryData(decofileKey) === undefined) {
void queryClient.invalidateQueries({ queryKey: decofileKey });
}
}
// Dev server just came up: swap the CMS queries off the committed
// `.deco/*.gen.json` snapshot and onto the live routes. This is the
// trigger they rely on (they're enabled as soon as the daemon is up,
Expand All @@ -343,15 +399,14 @@ export function SandboxEventsProvider({
// panels never mount on a cold-start open — only a hard refresh
// re-fetches the entity and picks it up.
invalidateVirtualMcpQueries(queryClient, org.id);
const cacheKey = `${org.slug}/${virtualMcpId}/${branch}`;
void queryClient.invalidateQueries({
queryKey: KEYS.decofile(cacheKey),
queryKey: KEYS.decofile(cacheKeyForBranch),
});
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "live-meta" &&
typeof query.queryKey[1] === "string" &&
query.queryKey[1].startsWith(`${cacheKey}/`),
query.queryKey[1].startsWith(`${cacheKeyForBranch}/`),
});
}
prevLifecyclePhase = lp.state.phase;
Expand All @@ -371,6 +426,11 @@ export function SandboxEventsProvider({
}
return;
}
case "decofile":
setDecofileVersion(
(payload as DaemonEventPayload<"decofile">).version,
);
return;
case "status":
setStatus(payload as DaemonEventPayload<"status">);
return;
Expand Down Expand Up @@ -421,15 +481,16 @@ export function SandboxEventsProvider({
const isDecoFile =
filePath.startsWith(".deco/") || filePath.includes("/.deco/");
if (isDecoFile) {
// Only act while the dev server is running. When it's down
// (crashed/paused — the state the committed-snapshot fallback
// supports editing in), `blocks.gen.json` is a stale build
// artifact the dev server can't regenerate, so invalidating +
// refetching it would overwrite the optimistic cache update from
// useSaveBlock/useDeleteBlock and the edit would visibly revert.
// Leave the optimistic cache as the source of truth; the
// lifecycle→running transition re-invalidates onto the live routes.
if (prevLifecyclePhase !== "running") return;
// Act when the dev server is running OR Fast Preview is on (which
// renders via the daemon with no dev server). Otherwise
// (crashed/paused — committed-snapshot editing) `blocks.gen.json`
// is a stale build artifact the dev server can't regenerate, so
// invalidating + refetching it would overwrite the optimistic
// cache update from useSaveBlock/useDeleteBlock and the edit would
// visibly revert; leave the optimistic cache as the source of
// truth until the lifecycle→running transition re-invalidates.
const devServerRunning = prevLifecyclePhase === "running";
if (!devServerRunning && !fastPreviewActiveRef.current) return;
// Turn on the preview's loading overlay immediately — before the
// debounce below — so the pending refresh feels instant instead of
// only appearing once the reload finally fires.
Expand All @@ -451,15 +512,20 @@ export function SandboxEventsProvider({
if (decofileDebounceTimer) clearTimeout(decofileDebounceTimer);
decofileDebounceTimer = setTimeout(() => {
decofileDebounceTimer = null;
void queryClient.invalidateQueries({
queryKey: KEYS.decofile(cacheKey),
});
// Reload the preview iframe once the rebuild has landed — HMR
// Only refetch the decofile when the dev server is running (the
// live `/.decofile` route reflects the write). In Fast Preview
// the daemon serves the render straight from disk, so skip the
// refetch (it would revert an optimistic edit) and just reload.
if (devServerRunning) {
void queryClient.invalidateQueries({
queryKey: KEYS.decofile(cacheKey),
});
}
// Reload the preview iframe once the write has landed — HMR
// won't reflect a decofile edit, so this is the only thing that
// refreshes the rendered page after a Blocks save (or an
// external/agent `.deco/` write). Debounced with the
// invalidation above so it fires after the dev server rebuilds,
// not against the stale pre-rebuild page.
// external/agent `.deco/` write). Debounced so it fires after
// the write lands ("save → refresh").
for (const fn of reloadHandlers.current) {
try {
fn();
Expand Down Expand Up @@ -627,6 +693,7 @@ export function SandboxEventsProvider({
status,
branch: branchMeta,
notFound,
decofileVersion,
scripts,
activeProcesses,
getBuffer: (source: string) => buffers.current.get(source)?.get() ?? "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
shouldAutoRetryClaim,
reconcileClaimRetryEpisode,
MAX_CLAIM_AUTO_RETRIES,
resolvePreviewUrl,
sandboxPreviewKey,
type BranchMapEntryLike,
} from "./sandbox-lifecycle-context";

Expand Down Expand Up @@ -462,3 +464,105 @@ describe("reconcileClaimRetryEpisode", () => {
});
});
});

describe("sandboxPreviewKey", () => {
test("distinguishes branches of the same vmcp", () => {
expect(sandboxPreviewKey("vm1", "main")).not.toBe(
sandboxPreviewKey("vm1", "feat"),
);
});

test("distinguishes the same branch across vmcps", () => {
expect(sandboxPreviewKey("vm1", "main")).not.toBe(
sandboxPreviewKey("vm2", "main"),
);
});

test("a null branch is its own key, not a wildcard", () => {
expect(sandboxPreviewKey("vm1", null)).toBe("vm1::");
expect(sandboxPreviewKey("vm1", null)).not.toBe(
sandboxPreviewKey("vm1", "main"),
);
});
});

describe("resolvePreviewUrl", () => {
const KEY = sandboxPreviewKey("vm1", "main");
const SEED = { key: KEY, url: "https://claimed.example" };

test("the recorded entry wins over a seed", () => {
expect(
resolvePreviewUrl({
vmEntry: entry("agent-sandbox", "h1", "https://recorded.example"),
seeded: SEED,
key: KEY,
}),
).toBe("https://recorded.example");
});

test("falls back to the claim seed while the entity query is stale", () => {
// The window this exists for: the daemon is up and SANDBOX_START already
// returned its URL, but sandboxMap hasn't been refetched yet.
expect(resolvePreviewUrl({ vmEntry: null, seeded: SEED, key: KEY })).toBe(
"https://claimed.example",
);
});

test("falls back when an entry exists but carries no previewUrl", () => {
expect(
resolvePreviewUrl({
vmEntry: entry("agent-sandbox", "h1", null),
seeded: SEED,
key: KEY,
}),
).toBe("https://claimed.example");
});

test("ignores a seed claimed for another branch", () => {
// Guards the real hazard: painting the previous branch's sandbox after a
// switch. Stale seeds are scoped out, never reset.
expect(
resolvePreviewUrl({
vmEntry: null,
seeded: SEED,
key: sandboxPreviewKey("vm1", "other"),
}),
).toBeNull();
});

test("null with neither source", () => {
expect(
resolvePreviewUrl({ vmEntry: null, seeded: null, key: KEY }),
).toBeNull();
});
});

describe("resolvePreviewUrl + claim failure (integration of the two rules)", () => {
const KEY = sandboxPreviewKey("vm1", "main");
const SEED = { key: KEY, url: "https://claimed.example" };

test("a dropped seed leaves nothing to paint, so the errored card wins", () => {
// The provider passes `seeded: null` on a terminal claim failure. Encoded
// here because computePreviewState reads any previewUrl as "sandbox live"
// and would paint a dead iframe over the error.
expect(
resolvePreviewUrl({ vmEntry: null, seeded: null, key: KEY }),
).toBeNull();
});

test("a recorded entry still wins on failure — a failed restart can't blank a working preview", () => {
expect(
resolvePreviewUrl({
vmEntry: entry("agent-sandbox", "h1", "https://recorded.example"),
seeded: null,
key: KEY,
}),
).toBe("https://recorded.example");
});

test("the seed applies while no failure is present", () => {
expect(resolvePreviewUrl({ vmEntry: null, seeded: SEED, key: KEY })).toBe(
"https://claimed.example",
);
});
});
Loading
Loading