feat: consolidate project runtime deployment

This commit is contained in:
-Puter
2026-08-04 17:37:50 +05:30
parent 3fc80154e8
commit b4181a1271
23 changed files with 574 additions and 504 deletions
@@ -8,12 +8,6 @@ import { useProjectSetup } from "@/hooks/use-project-setup";
import { ProjectSetupStatus } from "./project-setup-status";
const handleRetry = () => {
// The setup query is reactive — a retry on the backend will append new
// events and the phase derivation updates automatically. No client-side
// refetch is needed; this is a no-op signal for assistive tech.
};
export interface ProjectSetupPanelProps {
readonly onClose: () => void;
readonly onEnterProject: () => void;
@@ -80,7 +74,10 @@ export const ProjectSetupPanel = ({
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
</div>
) : (
<ProjectSetupStatus onRetry={handleRetry} phase={setup.phase} />
<ProjectSetupStatus
onRetry={setup.kind === "ready" ? setup.retry : undefined}
phase={setup.phase}
/>
)}
{setup.kind === "ready" && setup.phase === "ready" ? (
+113 -23
View File
@@ -1,6 +1,7 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useQuery } from "convex/react";
import { useAction, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useState } from "react";
/**
* Setup event types emitted by the backend project-setup coordinator. The
@@ -43,18 +44,41 @@ export interface ProjectSetupEvent {
}
export interface ProjectSetupState {
readonly attempt: number;
readonly events: readonly ProjectSetupEvent[];
readonly phase: ProjectSetupPhase;
readonly retry: () => Promise<void>;
readonly retrying: boolean;
}
export type ProjectSetupResult =
| { readonly kind: "loading" }
| ({ readonly kind: "ready" } & ProjectSetupState);
/**
* Status of a project's durable runtime row, as returned by
* `projects.getSetup`. This is the source of truth for UI phase derivation: a
* stale `project.setup.failed` event from a prior attempt must NOT keep the UI
* blocked once the runtime has been retried into a non-terminal status.
*/
type RuntimeStatus =
| "requested"
| "creating_vm"
| "cloning"
| "checking_repository"
| "ready"
| "failed";
interface RuntimeRow {
readonly attempt: number;
readonly status: RuntimeStatus;
}
/**
* Minimal structural shape of the `projects.getSetup` return value. We define
* this locally rather than importing from generated code so the hook compiles
* before the backend regenerates its API manifest.
* before the backend regenerates its API manifest. The `runtime` field is the
* durable project runtime row (or null before the row exists).
*/
interface SetupQueryResult {
readonly events: readonly {
@@ -62,6 +86,15 @@ interface SetupQueryResult {
readonly payload: unknown;
readonly type: ProjectSetupEventType;
}[];
readonly runtime: {
readonly attempt: number;
readonly status: RuntimeStatus;
} | null;
}
interface RetrySetupResult {
readonly attempt: number;
readonly retried: boolean;
}
const getSetupRef = makeFunctionReference<
@@ -70,6 +103,19 @@ const getSetupRef = makeFunctionReference<
SetupQueryResult
>("projects:getSetup");
/**
* Authenticated retry endpoint. Resets a failed runtime to a non-terminal
* status, increments the attempt, and reschedules the existing
* `projectSetup:runSetup` coordinator. Returns `{ retried, attempt }`; a
* `retried: false` result means the runtime was not failed (e.g. already
* ready, or still in-progress) and nothing was re-run.
*/
const retrySetupRef = makeFunctionReference<
"action",
{ projectId: Id<"projects"> },
RetrySetupResult
>("projects:retrySetup");
const ORDERED_PHASES: readonly ProjectSetupPhase[] = [
"creating_workspace",
"cloning_repository",
@@ -97,28 +143,34 @@ const typeToPhase = (type: ProjectSetupEventType): ProjectSetupPhase => {
}
};
const RUNTIME_STATUS_TO_PHASE: Record<RuntimeStatus, ProjectSetupPhase> = {
checking_repository: "checking_repository",
cloning: "cloning_repository",
creating_vm: "creating_workspace",
failed: "blocked",
ready: "ready",
requested: "creating_workspace",
};
/**
* Derive the user-facing phase from the raw event stream.
* Derive the user-facing phase.
*
* - A `blocked` event always wins (terminal error state).
* - A `ready` event wins over any in-progress phase.
* - Otherwise we map observed events to the furthest in-progress phase.
* The durable runtime row is the source of truth. Only when no runtime row
* exists yet (the very first import, before the coordinator has persisted a
* row) do we fall back to the event stream. A historical `project.setup.failed`
* event from a prior attempt never blocks the UI once the current runtime is
* non-failed — we never consult events for terminality when a runtime exists.
*/
const derivePhase = (
events: readonly ProjectSetupEvent[]
events: readonly ProjectSetupEvent[],
runtime: RuntimeRow | null
): ProjectSetupPhase => {
if (events.length === 0) {
return "creating_workspace";
if (runtime) {
return RUNTIME_STATUS_TO_PHASE[runtime.status];
}
const hasBlocked = events.some(
(event) =>
event.type === "project.setup.failed" ||
event.type === "project.setup.blocked" ||
event.type === "project.preview.blocked"
);
if (hasBlocked) {
return "blocked";
if (events.length === 0) {
return "creating_workspace";
}
const hasReady = events.some(
@@ -142,9 +194,15 @@ const derivePhase = (
};
/**
* Subscribe to a project's setup lifecycle via the `projects.getSetup` query.
* Returns a loading sentinel while the first fetch is in flight, then a
* derived `{ phase, events }` state that components can render directly.
* Subscribe to a project's setup lifecycle via the `projects.getSetup` query,
* and expose an authenticated retry control bound to `projects:retrySetup`.
*
* Returns a loading sentinel while the first fetch is in flight, then a derived
* `{ phase, events, attempt, retry, retrying }` state. Phase is derived from
* the durable runtime row (the source of truth), so a historical failed event
* cannot keep the UI blocked once the runtime has been retried. Components
* never receive raw backend errors — `retry` resolves cleanly and surfaces
* progress via the reactive query once the coordinator reschedules.
*/
export const useProjectSetup = (
projectId: Id<"projects"> | undefined
@@ -153,8 +211,10 @@ export const useProjectSetup = (
getSetupRef,
projectId === undefined ? "skip" : { projectId }
);
const retrySetup = useAction(retrySetupRef);
const [retrying, setRetrying] = useState(false);
if (setup === undefined) {
if (projectId === undefined || setup === undefined) {
return { kind: "loading" };
}
@@ -163,6 +223,36 @@ export const useProjectSetup = (
payload: event.payload,
type: event.type,
}));
const phase = derivePhase(events);
return { events, kind: "ready", phase };
const runtime: RuntimeRow | null = setup.runtime
? { attempt: setup.runtime.attempt, status: setup.runtime.status }
: null;
const attempt = runtime?.attempt ?? 1;
// While a retry is in flight (between the user click and the reactive
// runtime-status update landing), force an in-progress phase so the UI never
// lingers on the stale blocked state from the prior failed attempt.
const basePhase = derivePhase(events, runtime);
const phase: ProjectSetupPhase =
retrying && basePhase === "blocked" ? "creating_workspace" : basePhase;
const retry = async (): Promise<void> => {
if (projectId === undefined || retrying) {
return;
}
// The retry endpoint is idempotent and self-scoping: it only re-runs when
// the runtime is failed. Swallow rejections so raw backend errors never
// reach the UI; progress is observed reactively through getSetup once the
// coordinator reschedules (runtime status flips to non-terminal).
setRetrying(true);
try {
await retrySetup({ projectId });
} catch {
// No user-visible raw error; the reactive query keeps the UI honest
// about the true runtime state.
} finally {
setRetrying(false);
}
};
return { attempt, events, kind: "ready", phase, retry, retrying };
};