Files

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 };