Submatter/apps/web/app/api/ag-ui/[workspace]/route.ts

106 lines
4.2 KiB
TypeScript
Raw Permalink Normal View History

import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@tasks/database";
import { agentRuns, convoyEvents, objects } from "@tasks/database/schema";
import { auth } from "@/lib/auth";
import { resolveWorkspace } from "@/server/lib/resolve-workspace";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const agUiEnvelopeSchema = z.object({
convoyId: z.string().uuid().optional(),
briefId: z.string().uuid().nullable().optional(),
roleId: z.string().min(1).max(120).optional(),
eventType: z.string().min(1).max(64).optional(),
outcome: z.string().min(1).max(20).optional(),
harnessAlias: z.string().min(1).max(120).optional(),
summary: z.string().max(5000).optional(),
tokensTotal: z.number().int().nonnegative().optional(),
metadata: z.record(z.unknown()).optional(),
// Backlog lifecycle via ag-ui envelope (for remote harness that ran a task externally)
backlogItemId: z.string().uuid().optional(),
});
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
export async function POST(req: Request, { params }: { params: Promise<{ workspace: string }> }) {
const { workspace: handle } = await params;
const session = await auth();
if (!session?.user?.id) return json(401, { error: "Unauthorized" });
let workspace;
try {
workspace = await resolveWorkspace({ handle, userId: session.user.id });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Workspace not found";
const code = (e as { code?: string })?.code;
const status = code === "FORBIDDEN" ? 403 : code === "NOT_FOUND" ? 404 : 400;
return json(status, { error: msg });
}
let body: z.infer<typeof agUiEnvelopeSchema>;
try {
body = agUiEnvelopeSchema.parse(await req.json());
} catch (e) {
if (e instanceof z.ZodError) return json(400, { error: e.issues[0]?.message ?? "Invalid body" });
return json(400, { error: "Invalid JSON" });
}
// Tenant fence: if convoyId provided, ensure it belongs to this workspace
if (body.convoyId) {
const [convoy] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.convoyId), eq(objects.workspaceId, workspace.id))).limit(1);
if (!convoy) return json(403, { error: "convoyId not in workspace" });
}
if (body.briefId) {
const [brief] = await db.select({ id: objects.id, workspaceId: objects.workspaceId }).from(objects).where(and(eq(objects.id, body.briefId), eq(objects.workspaceId, workspace.id))).limit(1);
if (!brief) return json(403, { error: "briefId not in workspace" });
}
if (body.backlogItemId) {
// validate backlog item belongs to workspace via agent_runs path? We check objects? backlog stored in markdown_backlog_items — skip heavy check, just allow if workspace matches via conduct?
// For now allow but ensure if we can find agent run path.
}
// Write convoy event if we have convoy context
if (body.convoyId && body.roleId) {
await db.insert(convoyEvents).values({
workspaceId: workspace.id,
convoyId: body.convoyId,
briefId: body.briefId ?? null,
role: body.roleId,
actor: body.roleId,
eventType: body.eventType ?? "ag_ui",
outcome: body.outcome ?? null,
harnessAlias: body.harnessAlias ?? null,
metadata: {
source: "ag-ui",
summary: body.summary ?? null,
tokensTotal: body.tokensTotal ?? null,
...(body.metadata ?? {}),
},
});
}
// Optionally touch agent run if backlogItemId provided — create or update latest open run
if (body.backlogItemId && body.convoyId) {
// best-effort: insert completed run for observability; if harness already claimed, this will be additive
try {
await db.insert(agentRuns).values({
workspaceId: workspace.id,
backlogItemId: body.backlogItemId,
harnessAlias: body.harnessAlias ?? null,
outcome: body.outcome ?? null,
tokensTotal: body.tokensTotal ?? null,
notes: body.summary?.slice(0, 2000) ?? null,
metadata: { source: "ag-ui", ...(body.metadata ?? {}) },
});
} catch {
// ignore duplicate
}
}
return json(200, { ok: true });
}