254 lines
8.6 KiB
TypeScript
254 lines
8.6 KiB
TypeScript
import { env } from "@code/env/agent";
|
|
import { dispatch } from "@flue/runtime";
|
|
import { createAgentRouter } from "@flue/runtime/routing";
|
|
import { Hono } from "hono";
|
|
|
|
import {
|
|
cloneRepository,
|
|
getOrCreateProjectVm,
|
|
describeRepository,
|
|
scanEnvExample,
|
|
} from "./adapters/agentos.ts";
|
|
import { ProjectConversationAgent } from "./agents/project-agent.ts";
|
|
import { registerModelProvider } from "./model-config.ts";
|
|
import { registry } from "./runner.ts";
|
|
|
|
// Register the Cheaptricks OpenAI-compatible provider before the server
|
|
// starts handling requests. The API key is resolved per-request inside the
|
|
// provider's auth resolver and is never exposed to model tools.
|
|
registerModelProvider();
|
|
|
|
const app = new Hono();
|
|
|
|
// --- Health (unauthenticated liveness) --------------------------------------
|
|
app.get("/health", (c) => c.json({ service: "zopu-agents", status: "ok" }));
|
|
|
|
// --- Auth -------------------------------------------------------------------
|
|
|
|
/** Validate the bearer token against FLUE_DB_TOKEN. */
|
|
const requireServiceAuth = (authHeader: string | undefined): boolean => {
|
|
if (!authHeader) {
|
|
return false;
|
|
}
|
|
const match = /^Bearer\s+(?<token>.+)$/u.exec(authHeader);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
return match.groups?.token === env.FLUE_DB_TOKEN;
|
|
};
|
|
|
|
// --- Internal project-setup endpoint ----------------------------------------
|
|
//
|
|
// Called by the Convex `projectSetup.runSetup` coordinator to perform the
|
|
// AgentOS VM get-or-create, shallow clone, readability verification, and root
|
|
// `.env.example` name-only scan. Returns exactly the coordinator contract.
|
|
//
|
|
// Convex request body (callSetupEndpoint in projectSetup.ts):
|
|
// { branch, projectId, repositoryUrl }
|
|
//
|
|
// Coordinator-expected response (SetupRuntimeResult):
|
|
// { runtimeId?, vmId?, workspacePath?, repositoryPath?,
|
|
// repositoryCommit?, environmentVariableNames? }
|
|
interface ProjectSetupRequest {
|
|
branch: string;
|
|
projectId: string;
|
|
repositoryUrl: string;
|
|
}
|
|
|
|
export interface ProjectSetupResult {
|
|
runtimeId?: string;
|
|
vmId?: string;
|
|
workspacePath?: string;
|
|
repositoryPath?: string;
|
|
repositoryCommit?: string;
|
|
environmentVariableNames?: string[];
|
|
}
|
|
|
|
/**
|
|
* POST /internal/project-setup
|
|
*
|
|
* Single-call setup coordinator target. Performs:
|
|
* 1. Get-or-create the project workspace VM (idempotent by projectId).
|
|
* 2. Shallow single-branch clone (idempotent — skips if repo dir exists).
|
|
* 3. Verify readability: HEAD commit + root listing + read one file.
|
|
* 4. Scan root `.env.example` for variable names only (values never stored).
|
|
*
|
|
* Never mutates repository source files. The returned object matches the
|
|
* Convex `SetupRuntimeResult` wire shape exactly.
|
|
*/
|
|
app.post("/internal/project-setup", async (c) => {
|
|
if (!requireServiceAuth(c.req.header("authorization"))) {
|
|
return c.json({ error: "unauthorized" }, 401);
|
|
}
|
|
|
|
let body: ProjectSetupRequest;
|
|
try {
|
|
body = (await c.req.json()) as ProjectSetupRequest;
|
|
} catch {
|
|
return c.json({ error: "invalid JSON body" }, 400);
|
|
}
|
|
|
|
if (
|
|
typeof body.projectId !== "string" ||
|
|
body.projectId.length === 0 ||
|
|
typeof body.repositoryUrl !== "string" ||
|
|
body.repositoryUrl.length === 0 ||
|
|
typeof body.branch !== "string" ||
|
|
body.branch.length === 0
|
|
) {
|
|
return c.json({ error: "invalid project-setup request" }, 400);
|
|
}
|
|
|
|
try {
|
|
const vm = await getOrCreateProjectVm(body.projectId);
|
|
|
|
// Shallow clone (idempotent). Credentials are never exposed.
|
|
const { commit } = await cloneRepository({
|
|
branch: body.branch,
|
|
projectId: body.projectId,
|
|
repositoryUrl: body.repositoryUrl,
|
|
});
|
|
|
|
// Verify readability: HEAD commit + root listing (throws on failure).
|
|
await describeRepository(body.projectId);
|
|
|
|
// Scan root .env.example names-only.
|
|
const { names } = await scanEnvExample(body.projectId);
|
|
|
|
const result: ProjectSetupResult = {
|
|
environmentVariableNames: names,
|
|
repositoryCommit: commit,
|
|
repositoryPath: vm.repositoryPath,
|
|
runtimeId: vm.vmId,
|
|
vmId: vm.vmId,
|
|
workspacePath: vm.workspacePath,
|
|
};
|
|
return c.json(result, 200);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return c.json({ detail: message, error: "project-setup failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// --- Private dispatch endpoint -----------------------------------------------
|
|
//
|
|
// Convex calls this with a bearer FLUE_DB_TOKEN to deliver an event to a
|
|
// project-bound conversation agent. The endpoint validates the envelope,
|
|
// calls Flue dispatch(), and returns 202 Accepted.
|
|
//
|
|
// Idempotency: the dispatchId is encoded in the signal's idempotencyKey so
|
|
// a retried Convex workflow step converges on the original submission.
|
|
|
|
export interface DispatchEnvelope {
|
|
dispatchId: string;
|
|
// The triggering event. Convex sends sourceEventId separately for
|
|
// correlation; sourceEvent carries the event type + payload the agent acts on.
|
|
sourceEvent: {
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
// The Convex events row id of the source event (e.g. the project.ready id).
|
|
// Used for reply correlation; optional because some dispatches may be
|
|
// ad-hoc.
|
|
sourceEventId?: string;
|
|
agentId: string;
|
|
organizationId: string;
|
|
projectId: string;
|
|
// Correlation id tying all setup/dispatch events together.
|
|
correlationId?: string;
|
|
workId?: string;
|
|
threadId?: string;
|
|
}
|
|
|
|
app.post("/internal/agents/:agentId/events", async (c) => {
|
|
// Bearer auth.
|
|
if (!requireServiceAuth(c.req.header("authorization"))) {
|
|
return c.json({ error: "unauthorized" }, 401);
|
|
}
|
|
|
|
const routeAgentId = c.req.param("agentId");
|
|
let body: DispatchEnvelope;
|
|
try {
|
|
body = (await c.req.json()) as DispatchEnvelope;
|
|
} catch {
|
|
return c.json({ error: "invalid JSON body" }, 400);
|
|
}
|
|
|
|
// Validate the dispatch envelope.
|
|
if (
|
|
typeof body.dispatchId !== "string" ||
|
|
typeof body.agentId !== "string" ||
|
|
typeof body.organizationId !== "string" ||
|
|
typeof body.projectId !== "string" ||
|
|
!body.sourceEvent ||
|
|
typeof body.sourceEvent.type !== "string"
|
|
) {
|
|
return c.json({ error: "invalid dispatch envelope" }, 400);
|
|
}
|
|
|
|
// The route agentId must match the envelope agentId.
|
|
if (routeAgentId !== body.agentId) {
|
|
return c.json({ error: "agentId mismatch" }, 400);
|
|
}
|
|
|
|
// Build the stable conversation instance id. Must match
|
|
// conversationAgents.register: `conversation:<org>:<project>:v1`.
|
|
// The envelope agentId is already in that form.
|
|
const conversationId = body.agentId;
|
|
|
|
// Dispatch into Flue as a signal carrying the source event.
|
|
// The signal's attributes carry dispatch metadata so the agent's tools
|
|
// can resolve project/org/dispatch context during the turn.
|
|
try {
|
|
await dispatch(ProjectConversationAgent, {
|
|
id: conversationId,
|
|
idempotencyKey: `event:${body.dispatchId}:agent:${body.agentId}:handler:v1`,
|
|
message: {
|
|
attributes: {
|
|
agentId: body.agentId,
|
|
correlationId: body.correlationId ?? "",
|
|
dispatchId: body.dispatchId,
|
|
organizationId: body.organizationId,
|
|
projectId: body.projectId,
|
|
sourceEventId: body.sourceEventId ?? "",
|
|
threadId: body.threadId ?? "",
|
|
workId: body.workId ?? "",
|
|
},
|
|
body: JSON.stringify(body.sourceEvent),
|
|
kind: "signal",
|
|
type: body.sourceEvent.type,
|
|
},
|
|
});
|
|
return c.json(
|
|
{ accepted: true, conversationId, dispatchId: body.dispatchId },
|
|
202
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return c.json({ detail: message, error: "dispatch failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// --- Public agent router (protected, optional) -------------------------------
|
|
// Mounted for direct conversation access. Protected by the same bearer auth.
|
|
// The dispatch-only path above is the primary entry for Convex → agent.
|
|
const agentSubApp = createAgentRouter(ProjectConversationAgent);
|
|
const protectedAgent = new Hono();
|
|
protectedAgent.use("*", async (c, next) => {
|
|
if (!requireServiceAuth(c.req.header("authorization"))) {
|
|
return c.json({ error: "unauthorized" }, 401);
|
|
}
|
|
return await 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;
|