182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
import { ConvexError, v } from "convex/values";
|
|
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import { internalMutation, internalQuery } from "./_generated/server";
|
|
|
|
const RUNTIME_STATUS = [
|
|
"requested",
|
|
"creating_vm",
|
|
"cloning",
|
|
"checking_repository",
|
|
"ready",
|
|
"failed",
|
|
] as const;
|
|
|
|
const runtimeStatus = v.union(
|
|
v.literal("requested"),
|
|
v.literal("creating_vm"),
|
|
v.literal("cloning"),
|
|
v.literal("checking_repository"),
|
|
v.literal("ready"),
|
|
v.literal("failed")
|
|
);
|
|
|
|
/**
|
|
* Create or reuse a ProjectRuntime row, keyed idempotently by projectId.
|
|
* Idempotency key: `project:<projectId>:vm:v1` (per the shared contract).
|
|
* Re-runs (retries) return the existing row without duplicating it.
|
|
*/
|
|
export const requestRuntime = internalMutation({
|
|
args: {
|
|
idempotencyKey: v.string(),
|
|
organizationId: v.id("organizations"),
|
|
projectId: v.id("projects"),
|
|
provider: v.string(),
|
|
},
|
|
handler: async (ctx, args): Promise<Id<"projectRuntimes">> => {
|
|
const existing = await ctx.db
|
|
.query("projectRuntimes")
|
|
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
|
|
.unique();
|
|
if (existing) {
|
|
return existing._id;
|
|
}
|
|
const timestamp = Date.now();
|
|
return await ctx.db.insert("projectRuntimes", {
|
|
attempt: 1,
|
|
createdAt: timestamp,
|
|
idempotencyKey: args.idempotencyKey,
|
|
organizationId: args.organizationId,
|
|
projectId: args.projectId,
|
|
provider: args.provider,
|
|
status: "requested",
|
|
updatedAt: timestamp,
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Advance a ProjectRuntime to a new phase. A terminal "ready" status is never
|
|
* overwritten, so a completed setup is never partially rewound. Phase writes
|
|
* are scoped to a setup attempt: a write whose `attempt` predates the row's
|
|
* current attempt is treated as a no-op (it belongs to a superseded setup
|
|
* attempt), which lets a retry run with fresh, non-colliding lifecycle events.
|
|
* Idempotent: re-applying the same status within the same attempt is a no-op.
|
|
*/
|
|
export const markStatus = internalMutation({
|
|
args: {
|
|
attempt: v.optional(v.number()),
|
|
lastError: v.optional(v.string()),
|
|
repositoryCommit: v.optional(v.string()),
|
|
repositoryPath: v.optional(v.string()),
|
|
runtimeId: v.optional(v.string()),
|
|
runtimeRowId: v.id("projectRuntimes"),
|
|
status: runtimeStatus,
|
|
vmId: v.optional(v.string()),
|
|
workspacePath: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<Doc<"projectRuntimes">> => {
|
|
const runtime = await ctx.db.get(args.runtimeRowId);
|
|
if (!runtime) {
|
|
throw new ConvexError("Project runtime not found");
|
|
}
|
|
const currentAttempt = runtime.attempt ?? 1;
|
|
// Never rewind a terminal "ready" state. A retry that re-enters the
|
|
// coordinator after success must observe readiness, not re-run phases.
|
|
if (runtime.status === "ready" && args.status !== "ready") {
|
|
return runtime;
|
|
}
|
|
// Stale-attempt guard: once a runtime has advanced to a newer attempt
|
|
// (via retry), phase writes from a prior attempt are ignored. This
|
|
// preserves prior history without letting an old attempt suppress a
|
|
// current-attempt transition. The failed-write that ended the prior
|
|
// attempt has the same attempt value, so this never blocks the in-flight
|
|
// attempt from recording its own failure.
|
|
if (args.attempt !== undefined && args.attempt < currentAttempt) {
|
|
return runtime;
|
|
}
|
|
const patch: Record<string, unknown> = {
|
|
status: args.status,
|
|
updatedAt: Date.now(),
|
|
};
|
|
if (args.runtimeId !== undefined) {
|
|
patch.runtimeId = args.runtimeId;
|
|
}
|
|
if (args.vmId !== undefined) {
|
|
patch.vmId = args.vmId;
|
|
}
|
|
if (args.workspacePath !== undefined) {
|
|
patch.workspacePath = args.workspacePath;
|
|
}
|
|
if (args.repositoryPath !== undefined) {
|
|
patch.repositoryPath = args.repositoryPath;
|
|
}
|
|
if (args.repositoryCommit !== undefined) {
|
|
patch.repositoryCommit = args.repositoryCommit;
|
|
}
|
|
if (args.lastError !== undefined) {
|
|
patch.lastError = args.lastError;
|
|
}
|
|
await ctx.db.patch(args.runtimeRowId, patch);
|
|
const updated = await ctx.db.get(args.runtimeRowId);
|
|
if (!updated) {
|
|
throw new ConvexError("Project runtime could not be read after update");
|
|
}
|
|
return updated;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Reset a failed ProjectRuntime for a fresh setup attempt: increment the
|
|
* durable attempt counter, flip status back to "requested", and clear the
|
|
* prior failure metadata. Only a "failed" runtime may be retried; any other
|
|
* status (including "ready") is returned unchanged with `retried: false` so a
|
|
* retry of an already-ready runtime never re-runs setup. History (prior
|
|
* lifecycle/ready/failed events) is preserved — only the runtime row moves
|
|
* forward, and the new attempt's coordinator uses distinct idempotency keys.
|
|
*/
|
|
export const retry = internalMutation({
|
|
args: { runtimeRowId: v.id("projectRuntimes") },
|
|
handler: async (
|
|
ctx,
|
|
args
|
|
): Promise<{ retried: boolean; runtime: Doc<"projectRuntimes"> }> => {
|
|
const runtime = await ctx.db.get(args.runtimeRowId);
|
|
if (!runtime) {
|
|
throw new ConvexError("Project runtime not found");
|
|
}
|
|
if (runtime.status !== "failed") {
|
|
return { retried: false, runtime };
|
|
}
|
|
const nextAttempt = (runtime.attempt ?? 1) + 1;
|
|
await ctx.db.patch(args.runtimeRowId, {
|
|
attempt: nextAttempt,
|
|
lastError: undefined,
|
|
status: "requested",
|
|
updatedAt: Date.now(),
|
|
});
|
|
const updated = await ctx.db.get(args.runtimeRowId);
|
|
if (!updated) {
|
|
throw new ConvexError("Project runtime could not be read after update");
|
|
}
|
|
return { retried: true, runtime: updated };
|
|
},
|
|
});
|
|
|
|
export const getForProject = internalQuery({
|
|
args: { projectId: v.id("projects") },
|
|
handler: async (ctx, args): Promise<Doc<"projectRuntimes"> | null> =>
|
|
await ctx.db
|
|
.query("projectRuntimes")
|
|
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
|
|
.unique(),
|
|
});
|
|
|
|
export const get = internalQuery({
|
|
args: { runtimeRowId: v.id("projectRuntimes") },
|
|
handler: async (ctx, args): Promise<Doc<"projectRuntimes"> | null> =>
|
|
await ctx.db.get(args.runtimeRowId),
|
|
});
|
|
|
|
export { RUNTIME_STATUS };
|