feat: consolidate project runtime deployment
This commit is contained in:
@@ -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" ? (
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user