Files
zopu-code/packages/backend/convex/projectSetup.ts
2026-08-04 17:37:50 +05:30

560 lines
18 KiB
TypeScript

"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<n>` 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<n>` 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<string, string> => {
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<RuntimeRow> | 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<void> => {
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<SetupRuntimeResult> => {
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:<org>:<project>: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,
};