feat: consolidate project runtime deployment
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import git from "@agentos-software/git";
|
||||
import { agentOS } from "@rivet-dev/agentos";
|
||||
|
||||
/**
|
||||
* Factory for the durable AgentOS project workspace actor.
|
||||
*
|
||||
* One VM is created per project (keyed structurally by [projectId]). The actor
|
||||
* bundles Git alongside the AgentOS default utilities so the runtime can clone
|
||||
* and inspect repositories inside an isolated sandbox.
|
||||
*
|
||||
* The actor is defined in a shared module so the same shape is used by:
|
||||
* - `src/runner.ts` — typed registry export used by `src/app.ts` to start the
|
||||
* in-process envoy (native Mode A) and by the AgentOS adapter for
|
||||
* client-side typing.
|
||||
*
|
||||
* `onBeforeConnect` validates the shared workspace token so only this
|
||||
* deployment's runtime can drive the actor.
|
||||
*/
|
||||
export const createProjectWorkspaceActor = (token: string) =>
|
||||
agentOS<undefined, { token: string }>({
|
||||
defaultSoftware: true,
|
||||
onBeforeConnect: (_context, params) => {
|
||||
if (params.token !== token) {
|
||||
throw new Error("Unauthorized workspace connection");
|
||||
}
|
||||
},
|
||||
// Git is required for clone, rev-parse, and read-only repository inspection.
|
||||
// defaultSoftware provides sh/coreutils/common utilities only.
|
||||
software: [git],
|
||||
});
|
||||
@@ -10,12 +10,15 @@ import type { registry } from "../runner";
|
||||
// by projectId, and exposes only safe read-oriented operations for the
|
||||
// onboarding runtime endpoint and the conversation agent's tools.
|
||||
//
|
||||
// The adapter talks to the durable AgentOS RivetKit actor registered in this
|
||||
// same process (src/runner.ts, served in serverless mode via the Hono app).
|
||||
// It caches only the lightweight actor client, never VM handles — so it
|
||||
// survives engine restarts and process recycling. Each operation re-resolves
|
||||
// the actor handle via getOrCreate + resolve, which is stateless and safe
|
||||
// across lifecycle boundaries.
|
||||
// The durable AgentOS RivetKit actor is hosted in-process by the Flue/Hono
|
||||
// Node process (native envoy Mode A, started from src/app.ts). This adapter
|
||||
// uses the typed registry export from src/runner.ts only for client-side
|
||||
// typing; it connects to the actor through the Rivet Engine, not an
|
||||
// in-process registry handler. It caches only
|
||||
// the lightweight actor client, never VM handles — so it survives engine
|
||||
// restarts and process recycling. Each operation re-resolves the actor handle
|
||||
// via getOrCreate + resolve, which is stateless and safe across lifecycle
|
||||
// boundaries.
|
||||
//
|
||||
// It does NOT use Pi for onboarding — Pi capability is preserved only for
|
||||
// later GLM-5.2 work, and this module never invokes it.
|
||||
|
||||
@@ -22,6 +22,41 @@ const app = new Hono();
|
||||
|
||||
// --- Health (unauthenticated liveness) --------------------------------------
|
||||
app.get("/health", (c) => c.json({ service: "zopu-agents", status: "ok" }));
|
||||
// --- AgentOS envoy lifecycle (native Mode A) --------------------------------
|
||||
//
|
||||
// The durable project-workspace actor is hosted in-process. startAndWait()
|
||||
// opens the outbound envoy connection to the Rivet Engine and resolves only
|
||||
// once the envoy has registered. A rejection here propagates through the Flue
|
||||
// bootstrap (loadFlueNodeApplication) and crashes the process at startup
|
||||
// before the HTTP listener accepts traffic — the desired fail-fast behavior.
|
||||
//
|
||||
// shutdown.disableSignalHandlers is set on the registry (see runner.ts), so
|
||||
// this module owns registry teardown. The Flue-generated entry retains
|
||||
// process-exit ownership; this handler only drains the registry in parallel
|
||||
// with Flue's own graceful-shutdown drain and never calls process.exit().
|
||||
await registry.startAndWait();
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdownRegistry = async (): Promise<void> => {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
try {
|
||||
await registry.shutdown();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[agents] registry.shutdown() failed during shutdown:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
process.on("SIGINT", () => {
|
||||
void shutdownRegistry();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdownRegistry();
|
||||
});
|
||||
|
||||
// --- Auth -------------------------------------------------------------------
|
||||
|
||||
@@ -243,11 +278,4 @@ protectedAgent.use("*", async (c, next) => {
|
||||
protectedAgent.route("/", agentSubApp as unknown as Hono);
|
||||
app.route("/agents/project-conversation", protectedAgent);
|
||||
|
||||
// --- In-process RivetKit registry (serverless handler) -----------------------
|
||||
//
|
||||
// The AgentOS registry runs in serverless mode inside this same process.
|
||||
// Mounted on a private path so only internal callers (and the Engine via
|
||||
// configurePool.url) reach it. registry.handler drives the per-request
|
||||
// serverless runtime; importing the registry object performs no start.
|
||||
app.all("/internal/rivet/*", (c) => registry.handler(c.req.raw));
|
||||
export default app;
|
||||
|
||||
@@ -1,64 +1,33 @@
|
||||
import git from "@agentos-software/git";
|
||||
import { env } from "@code/env/agent";
|
||||
import { agentOS, setup } from "@rivet-dev/agentos";
|
||||
import { setup } from "@rivet-dev/agentos";
|
||||
|
||||
import { createProjectWorkspaceActor } from "./actors/project-workspace.ts";
|
||||
|
||||
/**
|
||||
* AgentOS registry — owns the durable project workspace actor.
|
||||
* AgentOS registry hosted in-process by the Flue/Hono Node process in native
|
||||
* envoy Mode A.
|
||||
*
|
||||
* The actor is keyed structurally by [projectId] (project ids are globally
|
||||
* unique), so each project resolves to one dedicated VM across the process's
|
||||
* lifetime. The registry runs in serverless runtime mode and is served
|
||||
* in-process by the Hono app (see app.ts), which mounts
|
||||
* `registry.handler(c.req.raw)` at `/internal/rivet/*`. There is no
|
||||
* standalone runner process and no `startAndWait` entry point.
|
||||
* The registry opens one persistent outbound connection to the Rivet Engine
|
||||
* (`RIVET_ENDPOINT`). The Engine routes actor calls back over that connection,
|
||||
* so no inbound HTTP callback (`/api/rivet/*`, `configurePool`) is required.
|
||||
* This is the single-process topology: the Hono app and the durable actor
|
||||
* runtime share one process, and `registry.startAndWait()` is driven from
|
||||
* `app.ts` during module initialization.
|
||||
*
|
||||
* `configurePool.url` is the distinct endpoint the Engine invokes to reach
|
||||
* this registry handler; it is NOT the Engine control-plane URL (that stays
|
||||
* `endpoint: env.RIVET_ENDPOINT`). The actor still authenticates every
|
||||
* connection via `onBeforeConnect` against RIVET_WORKSPACE_TOKEN, so only
|
||||
* this deployment's adapter can drive the actor.
|
||||
*
|
||||
* Software: the actor bundles Git (@agentos-software/git) alongside the
|
||||
* AgentOS default utilities. defaultSoftware alone is common utilities, not
|
||||
* Git — without the explicit git entry, clone/rev-parse/grep would fail.
|
||||
*
|
||||
* Import-safe: this module exports a pure registry object and performs no
|
||||
* top-level process start. Importing it (including `import type`) does not
|
||||
* start a registry.
|
||||
* `shutdown.disableSignalHandlers` is set so the Flue-generated server entry
|
||||
* owns process signals (SIGINT/SIGTERM/disconnect). `app.ts` installs a
|
||||
* host-owned handler that awaits `registry.shutdown()` alongside Flue's own
|
||||
* drain; neither path calls `process.exit()` from here.
|
||||
*/
|
||||
export const projectWorkspace = createProjectWorkspaceActor(
|
||||
env.RIVET_WORKSPACE_TOKEN
|
||||
);
|
||||
|
||||
// Project workspace actor — one VM per project, with Git + default utilities.
|
||||
const projectWorkspace = agentOS<undefined, { token: string }>({
|
||||
defaultSoftware: true,
|
||||
onBeforeConnect: (_context, params) => {
|
||||
if (params.token !== env.RIVET_WORKSPACE_TOKEN) {
|
||||
throw new Error("Unauthorized workspace connection");
|
||||
}
|
||||
},
|
||||
// Git is required for clone, rev-parse, and read-only repository inspection.
|
||||
// defaultSoftware provides sh/coreutils/common utilities only.
|
||||
software: [git],
|
||||
});
|
||||
|
||||
// Internal mount path shared by the Hono app route and the registry's
|
||||
// serverless base path. Both MUST agree — the handler uses this base path to
|
||||
// route actor/metadata requests, and the app must mount it at the same path.
|
||||
export const RIVET_SERVERLESS_BASE_PATH = "/internal/rivet";
|
||||
|
||||
// Registry: the single actor type served by this process, in serverless mode.
|
||||
// `serverless.basePath` matches the app route mount point; `configurePool.url`
|
||||
// is the address the Engine calls back into this handler. Importing this
|
||||
// module does not start the registry — the handler is driven per-request by
|
||||
// the Hono app.
|
||||
export const registry = setup({
|
||||
configurePool: {
|
||||
url: env.RIVET_SERVERLESS_ENDPOINT,
|
||||
},
|
||||
endpoint: env.RIVET_ENDPOINT,
|
||||
logging: { level: "info" },
|
||||
serverless: {
|
||||
basePath: RIVET_SERVERLESS_BASE_PATH,
|
||||
},
|
||||
runtime: "native",
|
||||
shutdown: { disableSignalHandlers: true },
|
||||
use: {
|
||||
projectWorkspace,
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ export const requestRuntime = internalMutation({
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
return await ctx.db.insert("projectRuntimes", {
|
||||
attempt: 1,
|
||||
createdAt: timestamp,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
organizationId: args.organizationId,
|
||||
@@ -55,13 +56,16 @@ export const requestRuntime = internalMutation({
|
||||
});
|
||||
|
||||
/**
|
||||
* Advance a ProjectRuntime to a new phase. A terminal "ready" or "failed"
|
||||
* status cannot be overwritten by an earlier phase on retry, so a completed
|
||||
* setup is never partially rewound. Idempotent: re-applying the same status
|
||||
* is a no-op.
|
||||
* 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()),
|
||||
@@ -76,12 +80,19 @@ export const markStatus = internalMutation({
|
||||
if (!runtime) {
|
||||
throw new ConvexError("Project runtime not found");
|
||||
}
|
||||
// Never rewind a terminal state. A retry that re-enters the coordinator
|
||||
// after success must observe readiness, not re-run phases.
|
||||
if (
|
||||
(runtime.status === "ready" || runtime.status === "failed") &&
|
||||
args.status !== runtime.status
|
||||
) {
|
||||
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> = {
|
||||
@@ -115,6 +126,43 @@ export const markStatus = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 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> =>
|
||||
|
||||
@@ -8,18 +8,30 @@ import type { Id } from "./_generated/dataModel";
|
||||
import { internalAction } from "./_generated/server";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
|
||||
// Idempotency keys (shared contract):
|
||||
// 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">) =>
|
||||
`project:${projectId}:ready: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
|
||||
) => `project:${projectId}:setup:${phase}:v1`;
|
||||
|
||||
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<
|
||||
@@ -35,6 +47,7 @@ const requestRuntimeRef = makeFunctionReference<
|
||||
const markRuntimeStatusRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{
|
||||
attempt?: number;
|
||||
runtimeRowId: Id<"projectRuntimes">;
|
||||
status: string;
|
||||
runtimeId?: string;
|
||||
@@ -164,6 +177,7 @@ interface SetupRuntimeResult {
|
||||
|
||||
interface RuntimeRow {
|
||||
_id: Id<"projectRuntimes">;
|
||||
attempt: number;
|
||||
status: string;
|
||||
organizationId: Id<"organizations">;
|
||||
}
|
||||
@@ -175,6 +189,7 @@ const asRuntimeRow = (doc: unknown): RuntimeRow => {
|
||||
}
|
||||
return {
|
||||
_id: row._id,
|
||||
attempt: row.attempt ?? 1,
|
||||
organizationId: row.organizationId,
|
||||
status: row.status ?? "requested",
|
||||
};
|
||||
@@ -313,6 +328,8 @@ export const runSetup = internalAction({
|
||||
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, {
|
||||
@@ -347,10 +364,11 @@ export const runSetup = internalAction({
|
||||
};
|
||||
}
|
||||
|
||||
const branch = source.defaultBranch ?? "main";
|
||||
const { attempt } = current;
|
||||
|
||||
// --- Phase: creating_vm ---
|
||||
await ctx.runMutation(markRuntimeStatusRef, {
|
||||
attempt,
|
||||
runtimeRowId,
|
||||
status: "creating_vm",
|
||||
});
|
||||
@@ -359,7 +377,8 @@ export const runSetup = internalAction({
|
||||
correlationId: args.correlationId,
|
||||
idempotencyKey: lifecycleEventIdempotencyKey(
|
||||
args.projectId,
|
||||
"creating_vm"
|
||||
"creating_vm",
|
||||
attempt
|
||||
),
|
||||
organizationId: args.organizationId,
|
||||
payload: {},
|
||||
@@ -369,15 +388,19 @@ export const runSetup = internalAction({
|
||||
visibility: "internal",
|
||||
});
|
||||
|
||||
// --- Phase: cloning + checking_repository + env scan (single Hono call) ---
|
||||
await ctx.runMutation(markRuntimeStatusRef, {
|
||||
attempt,
|
||||
runtimeRowId,
|
||||
status: "cloning",
|
||||
});
|
||||
await ctx.runMutation(appendSystemEventRef, {
|
||||
actorService: "project-setup",
|
||||
correlationId: args.correlationId,
|
||||
idempotencyKey: lifecycleEventIdempotencyKey(args.projectId, "cloning"),
|
||||
idempotencyKey: lifecycleEventIdempotencyKey(
|
||||
args.projectId,
|
||||
"cloning",
|
||||
attempt
|
||||
),
|
||||
organizationId: args.organizationId,
|
||||
payload: { branch },
|
||||
projectId: args.projectId,
|
||||
@@ -396,6 +419,7 @@ export const runSetup = internalAction({
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await ctx.runMutation(markRuntimeStatusRef, {
|
||||
attempt,
|
||||
lastError: message,
|
||||
runtimeRowId,
|
||||
status: "failed",
|
||||
@@ -403,7 +427,7 @@ export const runSetup = internalAction({
|
||||
await ctx.runMutation(appendSystemEventRef, {
|
||||
actorService: "project-setup",
|
||||
correlationId: args.correlationId,
|
||||
idempotencyKey: `${readyEventIdempotencyKey(args.projectId)}:failed`,
|
||||
idempotencyKey: failedEventIdempotencyKey(args.projectId, attempt),
|
||||
organizationId: args.organizationId,
|
||||
payload: { error: message },
|
||||
projectId: args.projectId,
|
||||
@@ -421,6 +445,7 @@ export const runSetup = internalAction({
|
||||
|
||||
// --- Phase: checking_repository (persisted) ---
|
||||
await ctx.runMutation(markRuntimeStatusRef, {
|
||||
attempt,
|
||||
repositoryCommit: result.repositoryCommit,
|
||||
repositoryPath: result.repositoryPath,
|
||||
runtimeId: result.runtimeId,
|
||||
@@ -434,7 +459,8 @@ export const runSetup = internalAction({
|
||||
correlationId: args.correlationId,
|
||||
idempotencyKey: lifecycleEventIdempotencyKey(
|
||||
args.projectId,
|
||||
"checking_repository"
|
||||
"checking_repository",
|
||||
attempt
|
||||
),
|
||||
organizationId: args.organizationId,
|
||||
payload: {
|
||||
@@ -477,7 +503,7 @@ export const runSetup = internalAction({
|
||||
const readyEventId = await ctx.runMutation(appendSystemEventRef, {
|
||||
actorService: "project-setup",
|
||||
correlationId: args.correlationId,
|
||||
idempotencyKey: readyEventIdempotencyKey(args.projectId),
|
||||
idempotencyKey: readyEventIdempotencyKey(args.projectId, attempt),
|
||||
organizationId: args.organizationId,
|
||||
payload: {
|
||||
agentId: args.agentId,
|
||||
@@ -512,6 +538,7 @@ export const runSetup = internalAction({
|
||||
|
||||
// Runtime becomes terminal only after the ready event is durably delivered.
|
||||
await ctx.runMutation(markRuntimeStatusRef, {
|
||||
attempt,
|
||||
runtimeRowId,
|
||||
status: "ready",
|
||||
});
|
||||
@@ -524,5 +551,9 @@ export const runSetup = internalAction({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export { runtimeIdempotencyKey, readyEventIdempotencyKey };
|
||||
export {
|
||||
failedEventIdempotencyKey,
|
||||
lifecycleEventIdempotencyKey,
|
||||
readyEventIdempotencyKey,
|
||||
runtimeIdempotencyKey,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,33 @@ export const getProjectSource = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
/** Authorize a retry and read its runtime within the query context. Actions do
|
||||
* not have direct database access, so the authenticated user ID is derived by
|
||||
* the action and verified against the project's organization here. */
|
||||
export const getRetryableRuntime = internalQuery({
|
||||
args: { projectId: v.id("projects"), userId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const project = await ctx.db.get(args.projectId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
const membership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
q.eq("organizationId", project.organizationId).eq("userId", args.userId)
|
||||
)
|
||||
.unique();
|
||||
if (!membership) {
|
||||
throw new Error("Organization membership required");
|
||||
}
|
||||
const runtime = await ctx.db
|
||||
.query("projectRuntimes")
|
||||
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
|
||||
.unique();
|
||||
return { organizationId: project.organizationId, runtime };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Read-only project setup status for the setup screen: latest runtime row plus
|
||||
* the setup-scoped events. Auth-gated to project members.
|
||||
|
||||
@@ -32,6 +32,19 @@ const runSetupRef = makeFunctionReference<
|
||||
},
|
||||
unknown
|
||||
>("projectSetup:runSetup");
|
||||
const retryRuntimeRef = makeFunctionReference<
|
||||
"mutation",
|
||||
{ runtimeRowId: Id<"projectRuntimes"> },
|
||||
{ retried: boolean; runtime: Doc<"projectRuntimes"> }
|
||||
>("projectRuntimes:retry");
|
||||
const getRetryableRuntimeRef = makeFunctionReference<
|
||||
"query",
|
||||
{ projectId: Id<"projects">; userId: string },
|
||||
{
|
||||
organizationId: Id<"organizations">;
|
||||
runtime: Doc<"projectRuntimes"> | null;
|
||||
}
|
||||
>("projectSetupQueries:getRetryableRuntime");
|
||||
|
||||
const toProjectView = async (
|
||||
ctx: Parameters<typeof requireCurrentOrganization>[0],
|
||||
@@ -241,7 +254,11 @@ export const getSetup = query({
|
||||
)
|
||||
)
|
||||
.take(20);
|
||||
return { artifacts, events };
|
||||
const runtime = await ctx.db
|
||||
.query("projectRuntimes")
|
||||
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
|
||||
.unique();
|
||||
return { artifacts, events, runtime };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -345,6 +362,50 @@ export const importPublicGit = action({
|
||||
return outcome;
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Retry a failed project setup. Authenticated and project-member scoped: only a
|
||||
* failed ProjectRuntime is reset to "requested" with its durable attempt
|
||||
* incremented, after which the existing `projectSetup:runSetup` coordinator is
|
||||
* scheduled. A non-failed runtime (including an already-ready one) is left
|
||||
* untouched and re-running is skipped. The new attempt's lifecycle/ready/failed
|
||||
* events use distinct per-attempt idempotency keys, so they never collide with
|
||||
* or are suppressed by prior-attempt events; history is preserved.
|
||||
*
|
||||
* Returns `{ retried, attempt }`: `retried` is true only when a failed runtime
|
||||
* was actually reset; `attempt` is the runtime's current attempt number.
|
||||
*/
|
||||
export const retrySetup = action({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (
|
||||
ctx,
|
||||
args
|
||||
): Promise<{ retried: boolean; attempt: number }> => {
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const { organizationId, runtime } = await ctx.runQuery(
|
||||
getRetryableRuntimeRef,
|
||||
{ projectId: args.projectId, userId }
|
||||
);
|
||||
if (!runtime) {
|
||||
throw new ConvexError("Project has no setup runtime to retry");
|
||||
}
|
||||
const { retried, runtime: updated } = await ctx.runMutation(
|
||||
retryRuntimeRef,
|
||||
{ runtimeRowId: runtime._id }
|
||||
);
|
||||
if (!retried) {
|
||||
return { attempt: updated.attempt ?? 1, retried: false };
|
||||
}
|
||||
const attempt = updated.attempt ?? 1;
|
||||
const agentId = conversationAgentId(organizationId, args.projectId);
|
||||
await ctx.scheduler.runAfter(0, runSetupRef, {
|
||||
agentId,
|
||||
correlationId: `retry:${args.projectId}:attempt:${attempt}`,
|
||||
organizationId,
|
||||
projectId: args.projectId,
|
||||
});
|
||||
return { attempt, retried: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateInstructions = mutation({
|
||||
args: {
|
||||
|
||||
@@ -377,6 +377,7 @@ export default defineSchema({
|
||||
"version",
|
||||
]),
|
||||
projectRuntimes: defineTable({
|
||||
attempt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
idempotencyKey: v.string(),
|
||||
lastError: v.optional(v.string()),
|
||||
|
||||
@@ -155,4 +155,59 @@ describe("timeline", () => {
|
||||
setup.events.map((event: { readonly type: string }) => event.type)
|
||||
).toEqual(["project.setup.blocked"]);
|
||||
});
|
||||
|
||||
test("retries a failed runtime once while preserving stale-attempt safety", async () => {
|
||||
const t = newTest();
|
||||
const organization = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
const project = await t.mutation(api.projects.persistPublicGitImport, {
|
||||
remote: { documents: [], warnings: [] },
|
||||
source: {
|
||||
host: "github.com",
|
||||
normalizedUrl: "https://github.com/example/retry-project",
|
||||
projectName: "retry-project",
|
||||
repositoryPath: "example/retry-project",
|
||||
url: "https://github.com/example/retry-project.git",
|
||||
},
|
||||
userId: identityA.tokenIdentifier,
|
||||
});
|
||||
const runtimeId = await t.mutation(api.projectRuntimes.requestRuntime, {
|
||||
idempotencyKey: `project:${project.id}:vm:v1`,
|
||||
organizationId: organization._id,
|
||||
projectId: project.id,
|
||||
provider: "agentos",
|
||||
});
|
||||
await t.mutation(api.projectRuntimes.markStatus, {
|
||||
attempt: 1,
|
||||
lastError: "worker unavailable",
|
||||
runtimeRowId: runtimeId,
|
||||
status: "failed",
|
||||
});
|
||||
|
||||
const retried = await t.mutation(api.projectRuntimes.retry, {
|
||||
runtimeRowId: runtimeId,
|
||||
});
|
||||
expect(retried).toMatchObject({
|
||||
retried: true,
|
||||
runtime: { attempt: 2, status: "requested" },
|
||||
});
|
||||
expect(retried.runtime.lastError).toBeUndefined();
|
||||
|
||||
const staleWrite = await t.mutation(api.projectRuntimes.markStatus, {
|
||||
attempt: 1,
|
||||
lastError: "late failure",
|
||||
runtimeRowId: runtimeId,
|
||||
status: "failed",
|
||||
});
|
||||
expect(staleWrite).toMatchObject({ attempt: 2, status: "requested" });
|
||||
|
||||
const duplicateRetry = await t.mutation(api.projectRuntimes.retry, {
|
||||
runtimeRowId: runtimeId,
|
||||
});
|
||||
expect(duplicateRetry).toMatchObject({
|
||||
retried: false,
|
||||
runtime: { attempt: 2, status: "requested" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-25
@@ -58,31 +58,7 @@ export const env = createEnv({
|
||||
{ message: "RIVET_ENDPOINT must be a valid URL or host:port" }
|
||||
)
|
||||
.transform((val) => (/^https?:\/\//u.test(val) ? val : `http://${val}`)),
|
||||
// Distinct endpoint the Rivet Engine invokes to reach this process's
|
||||
// in-process serverless registry handler (mounted by the Hono app at
|
||||
// RIVET_SERVERLESS_BASE_PATH). This is NOT the Engine control-plane URL
|
||||
// (that is RIVET_ENDPOINT); it is how the Engine calls back into the
|
||||
// registry handler co-hosted in this Node process. Defaults to the local
|
||||
// app listener.
|
||||
RIVET_SERVERLESS_ENDPOINT: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(val) => {
|
||||
try {
|
||||
const candidate = /^https?:\/\//u.test(val) ? val : `http://${val}`;
|
||||
void new URL(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "RIVET_SERVERLESS_ENDPOINT must be a valid URL or host:port",
|
||||
}
|
||||
)
|
||||
.transform((val) => (/^https?:\/\//u.test(val) ? val : `http://${val}`))
|
||||
.default("http://127.0.0.1:3000/internal/rivet"),
|
||||
// Service auth between the runtime and the AgentOS actor.
|
||||
RIVET_WORKSPACE_TOKEN: z.string().min(1),
|
||||
},
|
||||
skipValidation: process.env.SKIP_ENV_VALIDATION === "true",
|
||||
|
||||
Reference in New Issue
Block a user