560 lines
18 KiB
TypeScript
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,
|
|
};
|