feat: consolidate project runtime deployment

This commit is contained in:
-Puter
2026-08-04 17:37:50 +05:30
parent 3fc80154e8
commit b4181a1271
23 changed files with 574 additions and 504 deletions
@@ -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],
});
+9 -6
View File
@@ -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.
+35 -7
View File
@@ -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;
+20 -51
View File
@@ -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,
},
+58 -10
View File
@@ -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> =>
+46 -15
View File
@@ -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.
+62 -1
View File
@@ -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: {
+1
View File
@@ -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()),
+55
View File
@@ -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" },
});
});
});
+1 -25
View File
@@ -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",