- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation - Completion artifacts table 0017, deterministic summary template, gallery /artifacts - Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask - Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples - Allow pr_review→merged as automation for webhook Convoy: .convoys/owner-cockpit-v02 Co-authored-by: orchestrator
271 lines
9 KiB
TypeScript
271 lines
9 KiB
TypeScript
import { createLanguageModelFromResolved } from "@tasks/ai";
|
|
import {
|
|
markdownBacklogItems,
|
|
modelProviders,
|
|
roleModelRoutes,
|
|
} from "@tasks/database/schema";
|
|
import {
|
|
convoyObjectContentSchema,
|
|
GATE_INBOX_STATUSES,
|
|
} from "@tasks/shared/convoy-lifecycle";
|
|
import {
|
|
providerTypeSchema,
|
|
resolveModel,
|
|
type ModelProvider,
|
|
type RoleModelRoute,
|
|
} from "@tasks/shared/model-routing";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { generateText, tool } from "ai";
|
|
import { asc, eq } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
listConvoyProjects,
|
|
loadConvoyProject,
|
|
} from "@/server/lib/convoy/convoy-service";
|
|
import { router, workspaceProcedure } from "@/server/trpc";
|
|
import type { WorkspaceProcedureContext } from "@/server/trpc";
|
|
|
|
type ProviderRow = typeof modelProviders.$inferSelect;
|
|
type RouteRow = typeof roleModelRoutes.$inferSelect;
|
|
|
|
function mapProviderRow(row: ProviderRow): ModelProvider {
|
|
return {
|
|
id: row.id,
|
|
workspaceId: row.workspaceId,
|
|
alias: row.alias,
|
|
displayName: row.displayName,
|
|
providerType: providerTypeSchema.parse(row.providerType),
|
|
modelId: row.modelId,
|
|
baseUrl: row.baseUrl,
|
|
apiKeyEnvVar: row.apiKeyEnvVar,
|
|
extraHeaders: row.extraHeaders,
|
|
defaultParams: row.defaultParams,
|
|
metadata: row.metadata,
|
|
enabled: row.enabled,
|
|
};
|
|
}
|
|
|
|
function mapRouteRow(row: RouteRow): RoleModelRoute {
|
|
return {
|
|
id: row.id,
|
|
workspaceId: row.workspaceId,
|
|
roleId: row.roleId,
|
|
providerAliasId: row.providerAliasId,
|
|
enabled: row.enabled,
|
|
sortOrder: row.sortOrder,
|
|
};
|
|
}
|
|
|
|
function readApiKey(envVarName: string): string | undefined {
|
|
return process.env[envVarName]?.trim() || undefined;
|
|
}
|
|
|
|
function assertWorkspaceAdmin(_ctx: WorkspaceProcedureContext): void {
|
|
// ops copilot is read-only; no admin check needed, but keep helper for future
|
|
void _ctx;
|
|
}
|
|
|
|
const OPS_SYSTEM_PROMPT =
|
|
"You are ops copilot for second brain OS. You help owners triage \"what needs me?\" across fleet, convoys and backlog. " +
|
|
"Use the provided tools (fleetStatus, convoyGet, backlogListTree) to ground answers. " +
|
|
"Always respond with links in form [workspaceSlug/convoySlug] for convoys and [workspaceSlug/planSlug/epicSlug/taskSlug] for backlog items. " +
|
|
"Show fleet → needs you citations when answering gate questions. Be concise, actionable, no secrets.";
|
|
|
|
const messageSchema = z.object({
|
|
role: z.enum(["user", "assistant"]),
|
|
content: z.string().min(1).max(4000),
|
|
});
|
|
|
|
export const opsCopilotRouter = router({
|
|
generateText: workspaceProcedure
|
|
.input(
|
|
z.object({
|
|
workspace: z.string().min(1),
|
|
prompt: z.string().min(1).max(4000),
|
|
messages: z.array(messageSchema).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
void assertWorkspaceAdmin(ctx);
|
|
|
|
const providerRows = await ctx.db
|
|
.select()
|
|
.from(modelProviders)
|
|
.where(eq(modelProviders.workspaceId, ctx.workspace.id))
|
|
.orderBy(asc(modelProviders.alias));
|
|
|
|
const routeRows = await ctx.db
|
|
.select()
|
|
.from(roleModelRoutes)
|
|
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id));
|
|
|
|
const resolved = resolveModel({
|
|
roleId: "gate-copilot",
|
|
providers: providerRows.map(mapProviderRow),
|
|
routes: routeRows.map(mapRouteRow),
|
|
getApiKey: readApiKey,
|
|
});
|
|
|
|
if (!resolved.ok) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: resolved.error.message,
|
|
});
|
|
}
|
|
|
|
const modelResult = createLanguageModelFromResolved(resolved.model);
|
|
if (!modelResult.ok) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: modelResult.reason,
|
|
});
|
|
}
|
|
|
|
// Bounded, workspace-scoped, read-only tools (<3 tools)
|
|
const fleetStatusTool = tool({
|
|
description:
|
|
"Get fleet gate inbox needing you for this workspace. Returns up to 10 gate items with links [workspaceSlug/convoySlug].",
|
|
parameters: z.object({}),
|
|
execute: async () => {
|
|
const convoys = await listConvoyProjects(ctx.db, ctx.workspace.id);
|
|
const gateInbox = convoys.filter((c) =>
|
|
(GATE_INBOX_STATUSES as readonly string[]).includes(c.status),
|
|
);
|
|
const bounded = gateInbox.slice(0, 10);
|
|
const payload = {
|
|
workspace: ctx.workspace.slug,
|
|
totalNeedingYou: gateInbox.length,
|
|
items: bounded.map((c) => ({
|
|
convoyId: c.id,
|
|
convoySlug: c.content?.convoySlug ?? c.title,
|
|
status: c.status,
|
|
classification: c.content?.classification ?? null,
|
|
updatedAt: c.updatedAt.toISOString(),
|
|
link: `${ctx.workspace.slug}/${c.content?.convoySlug ?? c.id}`,
|
|
})),
|
|
};
|
|
return JSON.stringify(payload);
|
|
},
|
|
});
|
|
|
|
const convoyGetTool = tool({
|
|
description: "Get one convoy by id (workspace-scoped, read-only).",
|
|
parameters: z.object({
|
|
convoyId: z.string().uuid(),
|
|
}),
|
|
execute: async ({ convoyId }: { convoyId: string }) => {
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, convoyId);
|
|
if (!convoy) {
|
|
return JSON.stringify({ error: "Convoy not found in this workspace" });
|
|
}
|
|
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
|
const payload = {
|
|
id: convoy.id,
|
|
title: convoy.title,
|
|
status: convoy.status ?? "draft",
|
|
description: convoy.description ? convoy.description.slice(0, 1000) : null,
|
|
content: content.success ? content.data : null,
|
|
updatedAt: convoy.updatedAt.toISOString(),
|
|
link: `${ctx.workspace.slug}/${content.success ? content.data.convoySlug : convoy.title}`,
|
|
};
|
|
return JSON.stringify(payload);
|
|
},
|
|
});
|
|
|
|
const backlogListTreeTool = tool({
|
|
description:
|
|
"List backlog tree (plans/epics/tasks) for this workspace. Bounded to 20 items, read-only.",
|
|
parameters: z.object({}),
|
|
execute: async () => {
|
|
const rows = await ctx.db
|
|
.select({
|
|
id: markdownBacklogItems.id,
|
|
kind: markdownBacklogItems.kind,
|
|
slug: markdownBacklogItems.slug,
|
|
planSlug: markdownBacklogItems.planSlug,
|
|
epicSlug: markdownBacklogItems.epicSlug,
|
|
title: markdownBacklogItems.title,
|
|
status: markdownBacklogItems.status,
|
|
priority: markdownBacklogItems.priority,
|
|
repoPath: markdownBacklogItems.repoPath,
|
|
updatedAt: markdownBacklogItems.updatedAt,
|
|
})
|
|
.from(markdownBacklogItems)
|
|
.where(eq(markdownBacklogItems.workspaceId, ctx.workspace.id))
|
|
.orderBy(asc(markdownBacklogItems.planSlug), asc(markdownBacklogItems.kind), asc(markdownBacklogItems.slug));
|
|
|
|
const bounded = rows.slice(0, 20);
|
|
const payload = {
|
|
workspace: ctx.workspace.slug,
|
|
total: rows.length,
|
|
items: bounded.map((r) => ({
|
|
id: r.id,
|
|
kind: r.kind,
|
|
slug: r.slug,
|
|
planSlug: r.planSlug,
|
|
epicSlug: r.epicSlug,
|
|
title: r.title,
|
|
status: r.status,
|
|
priority: r.priority,
|
|
repoPath: r.repoPath,
|
|
link: `${ctx.workspace.slug}/${r.planSlug}${r.epicSlug ? `/${r.epicSlug}` : ""}/${r.slug}`,
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
})),
|
|
};
|
|
return JSON.stringify(payload);
|
|
},
|
|
});
|
|
|
|
const historyMessages = (input.messages ?? []).map((m) => ({
|
|
role: m.role as "user" | "assistant",
|
|
content: m.content,
|
|
}));
|
|
|
|
// Merge prompt as final user message; system is separate
|
|
const promptMessages = [
|
|
...historyMessages,
|
|
{ role: "user" as const, content: input.prompt },
|
|
];
|
|
|
|
const system = [
|
|
OPS_SYSTEM_PROMPT,
|
|
`Current workspace: ${ctx.workspace.name} (${ctx.workspace.slug})`,
|
|
`Workspace id: ${ctx.workspace.id}`,
|
|
].join("\n");
|
|
|
|
const { text, toolCalls, steps } = await generateText({
|
|
model: modelResult.model,
|
|
system,
|
|
messages: promptMessages,
|
|
tools: {
|
|
fleetStatus: fleetStatusTool,
|
|
convoyGet: convoyGetTool,
|
|
backlogListTree: backlogListTreeTool,
|
|
},
|
|
maxSteps: 3,
|
|
temperature: 0.4,
|
|
maxTokens: 1200,
|
|
});
|
|
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
throw new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
message: "Empty copilot response",
|
|
});
|
|
}
|
|
|
|
return {
|
|
text: trimmed,
|
|
resolvedAlias: resolved.resolvedAlias,
|
|
modelId: resolved.model.modelId,
|
|
providerType: resolved.model.providerType,
|
|
toolCalls: toolCalls ?? [],
|
|
steps: steps?.length ?? 0,
|
|
};
|
|
}),
|
|
});
|
|
|
|
// Alias for file naming: brief says copilot.ts with opsCopilot.generateText
|
|
export const copilotRouter = opsCopilotRouter;
|