Files
zopu-code/packages/agents/src/app.ts
2026-08-04 01:38:48 +05:30

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;