"use node"; import { env } from "@code/env/convex"; import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { internalAction } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; // Idempotency keys (shared contract). Setup attempt `1` preserves the original // keys verbatim (`...:v1`) so the existing import path is unchanged; a later // attempt appends `:a` so its lifecycle/ready/failed events can never // collide with or be suppressed by a prior attempt's events. /** Empty for the first attempt (preserving the original key space); `:a` for * any retry so its events are distinct from every prior attempt's. */ const attemptSuffix = (attempt: number): string => attempt <= 1 ? "" : `:a${attempt}`; const runtimeIdempotencyKey = (projectId: Id<"projects">) => `project:${projectId}:vm:v1`; const readyEventIdempotencyKey = (projectId: Id<"projects">, attempt: number) => `project:${projectId}:ready:v1${attemptSuffix(attempt)}`; const dispatchIdempotencyKey = (eventId: Id<"events">, agentId: string) => `event:${eventId}:agent:${agentId}:handler:v1`; const lifecycleEventIdempotencyKey = ( projectId: Id<"projects">, phase: string, attempt: number ) => `project:${projectId}:setup:${phase}:v1${attemptSuffix(attempt)}`; const failedEventIdempotencyKey = ( projectId: Id<"projects">, attempt: number ) => `${readyEventIdempotencyKey(projectId, attempt)}:failed`; const PROJECT_AGENT_TYPE = "project"; const requestRuntimeRef = makeFunctionReference< "mutation", { idempotencyKey: string; organizationId: Id<"organizations">; projectId: Id<"projects">; provider: string; }, Id<"projectRuntimes"> >("projectRuntimes:requestRuntime"); const markRuntimeStatusRef = makeFunctionReference< "mutation", { attempt?: number; runtimeRowId: Id<"projectRuntimes">; status: string; runtimeId?: string; vmId?: string; workspacePath?: string; repositoryPath?: string; repositoryCommit?: string; lastError?: string; }, unknown >("projectRuntimes:markStatus"); const getRuntimeRef = makeFunctionReference< "query", { projectId: Id<"projects"> }, unknown >("projectRuntimes:getForProject"); const replaceDetectedManifestRef = makeFunctionReference< "mutation", { names: string[]; organizationId: Id<"organizations">; projectId: Id<"projects">; }, { count: number } >("projectEnvironmentVariables:replaceDetectedManifest"); const registerAgentRef = makeFunctionReference< "mutation", { agentType: string; organizationId: Id<"organizations">; projectId: Id<"projects">; runtimeId?: Id<"projectRuntimes">; }, { _id: Id<"conversationAgents">; id: string } >("conversationAgents:register"); const markAgentStatusRef = makeFunctionReference< "mutation", { agentRowId: Id<"conversationAgents">; status: string }, unknown >("conversationAgents:markStatus"); const appendSystemEventRef = makeFunctionReference< "mutation", { actorService: string; correlationId: string; idempotencyKey: string; organizationId: Id<"organizations">; payload: unknown; projectId: Id<"projects">; scopeKind: "global" | "work"; type: string; visibility: "timeline" | "compact" | "internal"; }, Id<"events"> >("events:appendSystemEvent"); const createDispatchRef = makeFunctionReference< "mutation", { agentId: string; idempotencyKey: string; organizationId: Id<"organizations">; projectId: Id<"projects">; sourceEventId: Id<"events">; }, { created: boolean; dispatch: { _id: Id<"agentDispatches">; status: string } } >("agentDispatches:create"); const markDispatchSendingRef = makeFunctionReference< "mutation", { dispatchId: Id<"agentDispatches"> }, unknown >("agentDispatches:markSending"); const markDispatchAcceptedRef = makeFunctionReference< "mutation", { dispatchId: Id<"agentDispatches"> }, unknown >("agentDispatches:markAccepted"); const markDispatchCompletedRef = makeFunctionReference< "mutation", { dispatchId: Id<"agentDispatches"> }, unknown >("agentDispatches:markCompleted"); const markDispatchFailedRef = makeFunctionReference< "mutation", { dispatchId: Id<"agentDispatches">; error: string; retry: boolean }, unknown >("agentDispatches:markFailed"); const getProjectSourceRef = makeFunctionReference< "query", { projectId: Id<"projects"> }, { organizationId: Id<"organizations">; sourceUrl: string; defaultBranch: string | null; name: string; } | null >("projectSetupQueries:getProjectSource"); /** Resolve the agent backend URL. AGENT_BACKEND_URL takes precedence over FLUE_URL. */ const backendUrl = (): string => { const url = env.AGENT_BACKEND_URL ?? env.FLUE_URL; if (!url) { throw new ConvexError( "AGENT_BACKEND_URL or FLUE_URL must be configured for project setup" ); } return url; }; const authHeaders = (): Record => { if (!env.FLUE_DB_TOKEN) { throw new ConvexError("FLUE_DB_TOKEN must be configured for project setup"); } return { authorization: `Bearer ${env.FLUE_DB_TOKEN}`, "content-type": "application/json", }; }; /** Wire shape of the setup-coordinator result returned by the Hono endpoint. */ interface SetupRuntimeResult { runtimeId?: string; vmId?: string; workspacePath?: string; repositoryPath?: string; repositoryCommit?: string; environmentVariableNames?: string[]; } interface RuntimeRow { _id: Id<"projectRuntimes">; attempt: number; status: string; organizationId: Id<"organizations">; } const asRuntimeRow = (doc: unknown): RuntimeRow => { const row = doc as Partial | null; if (!row || !row._id || !row.organizationId) { throw new ConvexError("Project runtime row is malformed"); } return { _id: row._id, attempt: row.attempt ?? 1, organizationId: row.organizationId, status: row.status ?? "requested", }; }; interface DispatchReadyArgs { agentId: string; correlationId: string; organizationId: Id<"organizations">; projectId: Id<"projects">; readyEventId: Id<"events">; sourceEvent: { type: string; payload: unknown }; } const dispatchReadyWithEvent = async ( ctx: ActionCtx, args: DispatchReadyArgs ): Promise => { const created = (await ctx.runMutation(createDispatchRef, { agentId: args.agentId, idempotencyKey: dispatchIdempotencyKey(args.readyEventId, args.agentId), organizationId: args.organizationId, projectId: args.projectId, sourceEventId: args.readyEventId, })) as { created: boolean; dispatch: { _id: Id<"agentDispatches">; status: string }; }; const dispatchId = created.dispatch._id; // An already-completed dispatch from a prior run must never be re-sent; the // idempotency key guarantees this is the same logical delivery. if (created.dispatch.status === "completed") { return; } await ctx.runMutation(markDispatchSendingRef, { dispatchId }); try { const response = await fetch( `${backendUrl()}/internal/agents/${encodeURIComponent(args.agentId)}/events`, { body: JSON.stringify({ agentId: args.agentId, correlationId: args.correlationId, dispatchId, organizationId: args.organizationId, projectId: args.projectId, sourceEvent: args.sourceEvent, sourceEventId: args.readyEventId, }), headers: authHeaders(), method: "POST", } ); if (response.status === 202 || response.status === 200) { await ctx.runMutation(markDispatchAcceptedRef, { dispatchId }); await ctx.runMutation(markDispatchCompletedRef, { dispatchId }); return; } const text = await response.text().catch(() => ""); throw new Error( `Agent dispatch failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}` ); } catch (error) { const message = error instanceof Error ? error.message : String(error); await ctx.runMutation(markDispatchFailedRef, { dispatchId, error: message, retry: true, }); throw error; } }; const callSetupEndpoint = async (args: { branch: string; projectId: Id<"projects">; repositoryUrl: string; }): Promise => { const response = await fetch(`${backendUrl()}/internal/project-setup`, { body: JSON.stringify({ branch: args.branch, projectId: args.projectId, repositoryUrl: args.repositoryUrl, }), headers: authHeaders(), method: "POST", }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error( `Project setup endpoint returned ${response.status} ${response.statusText}${text ? ` — ${text}` : ""}` ); } const payload = (await response.json()) as unknown; if (typeof payload !== "object" || payload === null) { throw new Error("Project setup endpoint returned a malformed response"); } return payload as SetupRuntimeResult; }; /** * The durable project setup coordinator. Drives one Project through: * requested -> creating_vm -> cloning -> checking_repository -> ready * (or -> failed). * * It calls a single Hono internal setup endpoint that performs the AgentOS VM * get-or-create, shallow clone, readability verification, and `.env.example` * scan, returning the runtime facts. Each Convex-visible phase transition is * persisted to `projectRuntimes` so retries and the setup screen observe * progress. On success it registers the conversation agent, appends the * exact-once `project.ready` event, creates the `agentDispatches` row, and * dispatches the ready event to the Hono `POST /internal/agents/:agentId/events` * endpoint. * * This function never mutates the repository and never persists example/env * values. Idempotency keys guarantee retries create no duplicate VMs, agents, * events, or dispatches. * * `correlationId` ties all setup events together; `agentId` is the stable * `conversation:::v1` agent identity. */ export const runSetup = internalAction({ args: { agentId: v.string(), correlationId: v.string(), organizationId: v.id("organizations"), projectId: v.id("projects"), }, handler: async (ctx, args) => { const source = await ctx.runQuery(getProjectSourceRef, { projectId: args.projectId, }); if (!source || source.organizationId !== args.organizationId) { throw new ConvexError("Project not found within organization"); } const branch = source.defaultBranch ?? "main"; // Request (or reuse) the runtime row. Terminal-safe: a ready/failed row is // returned as-is by the mutation. const runtimeRowId = await ctx.runMutation(requestRuntimeRef, { idempotencyKey: runtimeIdempotencyKey(args.projectId), organizationId: args.organizationId, projectId: args.projectId, provider: "agentos", }); // Re-check terminal state: if already ready, just (re)dispatch ready. const current = asRuntimeRow( await ctx.runQuery(getRuntimeRef, { projectId: args.projectId, }) ); if (current.status === "ready") { // Terminal `ready` is only reached after the exact-once `project.ready` // event was appended AND its dispatch completed on a prior run, so a // re-entry simply reports readiness without re-running phases or // re-delivering (the dispatch idempotency key would no-op anyway). return { projectId: args.projectId, runtimeId: runtimeRowId, status: "ready", }; } if (current.status === "failed") { return { projectId: args.projectId, runtimeId: runtimeRowId, status: "failed", }; } const { attempt } = current; // --- Phase: creating_vm --- await ctx.runMutation(markRuntimeStatusRef, { attempt, runtimeRowId, status: "creating_vm", }); await ctx.runMutation(appendSystemEventRef, { actorService: "project-setup", correlationId: args.correlationId, idempotencyKey: lifecycleEventIdempotencyKey( args.projectId, "creating_vm", attempt ), organizationId: args.organizationId, payload: {}, projectId: args.projectId, scopeKind: "global", type: "project.setup.creating_vm", visibility: "internal", }); await ctx.runMutation(markRuntimeStatusRef, { attempt, runtimeRowId, status: "cloning", }); await ctx.runMutation(appendSystemEventRef, { actorService: "project-setup", correlationId: args.correlationId, idempotencyKey: lifecycleEventIdempotencyKey( args.projectId, "cloning", attempt ), organizationId: args.organizationId, payload: { branch }, projectId: args.projectId, scopeKind: "global", type: "project.setup.cloning", visibility: "internal", }); let result: SetupRuntimeResult; try { result = await callSetupEndpoint({ branch, projectId: args.projectId, repositoryUrl: source.sourceUrl, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); await ctx.runMutation(markRuntimeStatusRef, { attempt, lastError: message, runtimeRowId, status: "failed", }); await ctx.runMutation(appendSystemEventRef, { actorService: "project-setup", correlationId: args.correlationId, idempotencyKey: failedEventIdempotencyKey(args.projectId, attempt), organizationId: args.organizationId, payload: { error: message }, projectId: args.projectId, scopeKind: "global", type: "project.setup.failed", visibility: "internal", }); return { error: message, projectId: args.projectId, runtimeId: runtimeRowId, status: "failed", }; } // --- Phase: checking_repository (persisted) --- await ctx.runMutation(markRuntimeStatusRef, { attempt, repositoryCommit: result.repositoryCommit, repositoryPath: result.repositoryPath, runtimeId: result.runtimeId, runtimeRowId, status: "checking_repository", vmId: result.vmId, workspacePath: result.workspacePath, }); await ctx.runMutation(appendSystemEventRef, { actorService: "project-setup", correlationId: args.correlationId, idempotencyKey: lifecycleEventIdempotencyKey( args.projectId, "checking_repository", attempt ), organizationId: args.organizationId, payload: { repositoryCommit: result.repositoryCommit ?? null, runtimeId: result.runtimeId ?? null, vmId: result.vmId ?? null, }, projectId: args.projectId, scopeKind: "global", type: "project.setup.checking_repository", visibility: "internal", }); // Persist the detected env-name manifest (names only; non-blocking). const envNames = result.environmentVariableNames ?? []; await ctx.runMutation(replaceDetectedManifestRef, { names: envNames, organizationId: args.organizationId, projectId: args.projectId, }); // Register the one project-bound conversation agent (idempotent). const agent = (await ctx.runMutation(registerAgentRef, { agentType: PROJECT_AGENT_TYPE, organizationId: args.organizationId, projectId: args.projectId, runtimeId: runtimeRowId, })) as { _id: Id<"conversationAgents">; id: string }; await ctx.runMutation(markAgentStatusRef, { agentRowId: agent._id, status: "active", }); // --- Phase: ready --- // Append the exact-once `project.ready` event (the durable onboarding // trigger) and deliver it BEFORE flipping the runtime terminal. A delivery // failure must throw so Convex retries the coordinator; the runtime stays // non-terminal, and the idempotency keys make the retry reuse the same // event/dispatch rather than duplicating it. const readyEventId = await ctx.runMutation(appendSystemEventRef, { actorService: "project-setup", correlationId: args.correlationId, idempotencyKey: readyEventIdempotencyKey(args.projectId, attempt), organizationId: args.organizationId, payload: { agentId: args.agentId, environmentVariableNames: envNames, repositoryCommit: result.repositoryCommit ?? null, runtimeId: result.runtimeId ?? null, }, projectId: args.projectId, scopeKind: "global", type: "project.ready", visibility: "timeline", }); // Dispatch the ready event to the project conversation agent. Throws on // delivery failure; only success returns, after which `ready` is durable. await dispatchReadyWithEvent(ctx, { agentId: args.agentId, correlationId: args.correlationId, organizationId: args.organizationId, projectId: args.projectId, readyEventId, sourceEvent: { payload: { agentId: args.agentId, environmentVariableNames: envNames, repositoryCommit: result.repositoryCommit ?? null, runtimeId: result.runtimeId ?? null, }, type: "project.ready", }, }); // Runtime becomes terminal only after the ready event is durably delivered. await ctx.runMutation(markRuntimeStatusRef, { attempt, runtimeRowId, status: "ready", }); return { projectId: args.projectId, readyEventId, runtimeId: runtimeRowId, status: "ready", }; }, }); export { failedEventIdempotencyKey, lifecycleEventIdempotencyKey, readyEventIdempotencyKey, runtimeIdempotencyKey, };