2026-07-07 16:01:03 -04:00
|
|
|
import { createLanguageModelFromResolved } from "@tasks/ai";
|
2026-08-25 17:20:53 -04:00
|
|
|
import {
|
2026-08-25 18:49:38 -04:00
|
|
|
completionArtifacts,
|
2026-08-25 17:56:02 -04:00
|
|
|
convoyOperatorMessages,
|
2026-08-25 17:20:53 -04:00
|
|
|
modelProviders,
|
|
|
|
|
objects,
|
|
|
|
|
roleModelRoutes,
|
|
|
|
|
workspaceMembers,
|
|
|
|
|
workspaces,
|
|
|
|
|
} from "@tasks/database/schema";
|
2026-07-07 16:01:03 -04:00
|
|
|
import {
|
|
|
|
|
convoyClassificationSchema,
|
|
|
|
|
convoyObjectContentSchema,
|
|
|
|
|
ConvoyTransitionError,
|
|
|
|
|
GATE_INBOX_STATUSES,
|
|
|
|
|
humanGateActorForStatus,
|
|
|
|
|
MISSION_CONTROL_KANBAN_COLUMNS,
|
|
|
|
|
type ConvoyStatus,
|
|
|
|
|
} from "@tasks/shared/convoy-lifecycle";
|
|
|
|
|
import {
|
|
|
|
|
providerTypeSchema,
|
|
|
|
|
resolveModel,
|
|
|
|
|
type ModelProvider,
|
|
|
|
|
type RoleModelRoute,
|
|
|
|
|
} from "@tasks/shared/model-routing";
|
2026-08-25 18:49:38 -04:00
|
|
|
import { generateCompletionSummary } from "@tasks/shared/worker";
|
2026-07-07 16:01:03 -04:00
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
|
import { generateText } from "ai";
|
2026-08-25 18:49:38 -04:00
|
|
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
2026-07-07 16:01:03 -04:00
|
|
|
import { z } from "zod";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
findConvoyBySlug,
|
|
|
|
|
getConvoyBriefs,
|
2026-08-25 17:20:53 -04:00
|
|
|
getConvoyCheckpointSummary,
|
2026-08-25 17:56:02 -04:00
|
|
|
getConvoyPrUrl,
|
2026-07-07 16:01:03 -04:00
|
|
|
getRecentConvoyEvents,
|
|
|
|
|
insertConvoyEvent,
|
|
|
|
|
listConvoyProjects,
|
|
|
|
|
loadConvoyProject,
|
|
|
|
|
transitionConvoy,
|
|
|
|
|
} from "@/server/lib/convoy/convoy-service";
|
2026-08-25 17:20:53 -04:00
|
|
|
import { protectedProcedure, router, workspaceProcedure } from "@/server/trpc";
|
2026-07-07 16:01:03 -04:00
|
|
|
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 assertWorkspaceAdmin(ctx: WorkspaceProcedureContext): void {
|
|
|
|
|
if (ctx.workspace.role !== "owner" && ctx.workspace.role !== "admin") {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "FORBIDDEN",
|
|
|
|
|
message: "Only workspace owners and admins can manage convoys.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readApiKey(envVarName: string): string | undefined {
|
|
|
|
|
return process.env[envVarName]?.trim() || undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gateActionSchema = z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
action: z.enum(["approve", "revise", "request_changes", "promote"]),
|
|
|
|
|
reason: z.string().min(1).max(2_000).optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function resolveGateTransition(
|
|
|
|
|
status: ConvoyStatus,
|
|
|
|
|
action: z.infer<typeof gateActionSchema>["action"],
|
|
|
|
|
): { actor: string; toStatus: ConvoyStatus } {
|
|
|
|
|
const actor = humanGateActorForStatus(status);
|
|
|
|
|
if (!actor) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "BAD_REQUEST",
|
|
|
|
|
message: `No human gate applies to status ${status}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === "convoy_review") {
|
|
|
|
|
if (action === "approve") return { actor, toStatus: "ready" };
|
|
|
|
|
if (action === "revise") return { actor, toStatus: "draft" };
|
|
|
|
|
}
|
|
|
|
|
if (status === "pr_review") {
|
|
|
|
|
if (action === "approve") return { actor, toStatus: "merged" };
|
|
|
|
|
if (action === "request_changes") return { actor, toStatus: "in_progress" };
|
|
|
|
|
}
|
2026-08-25 17:20:53 -04:00
|
|
|
if (status === "doc_review") {
|
|
|
|
|
if (action === "approve") return { actor, toStatus: "published" };
|
|
|
|
|
if (action === "request_changes") return { actor, toStatus: "in_progress" };
|
|
|
|
|
}
|
|
|
|
|
if ((status === "merged" || status === "published") && action === "promote") {
|
2026-07-07 16:01:03 -04:00
|
|
|
return { actor, toStatus: "released" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "BAD_REQUEST",
|
|
|
|
|
message: `Action ${action} is not valid for status ${status}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const convoysRouter = router({
|
|
|
|
|
kanban: workspaceProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const convoys = await listConvoyProjects(ctx.db, ctx.workspace.id);
|
|
|
|
|
const columns = MISSION_CONTROL_KANBAN_COLUMNS.map((column) => ({
|
|
|
|
|
...column,
|
|
|
|
|
convoys: convoys.filter((convoy) => convoy.status === column.status),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
columns,
|
|
|
|
|
gateInbox: convoys.filter((convoy) => GATE_INBOX_STATUSES.includes(convoy.status)),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
get: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
|
|
|
|
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
|
|
|
|
|
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
|
2026-08-25 17:20:53 -04:00
|
|
|
const checkpoints = await getConvoyCheckpointSummary(
|
|
|
|
|
ctx.db,
|
|
|
|
|
ctx.workspace.id,
|
|
|
|
|
convoy.id,
|
|
|
|
|
);
|
2026-08-25 17:56:02 -04:00
|
|
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
|
2026-07-07 16:01:03 -04:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
convoy: {
|
|
|
|
|
id: convoy.id,
|
|
|
|
|
title: convoy.title,
|
|
|
|
|
status: convoy.status ?? "draft",
|
|
|
|
|
description: convoy.description,
|
|
|
|
|
content: content.success ? content.data : null,
|
|
|
|
|
updatedAt: convoy.updatedAt,
|
|
|
|
|
},
|
|
|
|
|
briefs,
|
|
|
|
|
events,
|
2026-08-25 17:20:53 -04:00
|
|
|
checkpoints: {
|
|
|
|
|
count: checkpoints.count,
|
|
|
|
|
lastOccurredAt: checkpoints.lastOccurredAt
|
|
|
|
|
? checkpoints.lastOccurredAt.toISOString()
|
|
|
|
|
: null,
|
|
|
|
|
},
|
2026-08-25 17:56:02 -04:00
|
|
|
pr,
|
2026-07-07 16:01:03 -04:00
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
create: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
slug: z
|
|
|
|
|
.string()
|
|
|
|
|
.min(1)
|
|
|
|
|
.max(120)
|
|
|
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
|
|
|
classification: convoyClassificationSchema,
|
|
|
|
|
skipFlags: z.array(z.string()).default([]),
|
|
|
|
|
successMetric: z.string().min(1).max(2_000),
|
|
|
|
|
ideaMarkdown: z.string().min(1),
|
2026-08-25 17:20:53 -04:00
|
|
|
planSlug: z
|
|
|
|
|
.string()
|
|
|
|
|
.min(1)
|
|
|
|
|
.max(200)
|
|
|
|
|
.regex(/^[a-z0-9-]+$/)
|
|
|
|
|
.optional(),
|
|
|
|
|
epicSlug: z
|
|
|
|
|
.string()
|
|
|
|
|
.min(1)
|
|
|
|
|
.max(200)
|
|
|
|
|
.regex(/^[a-z0-9-]+$/)
|
|
|
|
|
.optional(),
|
|
|
|
|
backlogItemId: z.string().uuid().optional(),
|
|
|
|
|
repoPath: z.string().min(1).optional(),
|
|
|
|
|
briefFiles: z.array(z.string()).optional(),
|
2026-07-07 16:01:03 -04:00
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
assertWorkspaceAdmin(ctx);
|
|
|
|
|
|
|
|
|
|
const existing = await findConvoyBySlug(ctx.db, ctx.workspace.id, input.slug);
|
|
|
|
|
if (existing) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "CONFLICT",
|
|
|
|
|
message: `Convoy slug '${input.slug}' already exists.`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = convoyObjectContentSchema.parse({
|
|
|
|
|
convoySlug: input.slug,
|
|
|
|
|
classification: input.classification,
|
|
|
|
|
skipFlags: input.skipFlags,
|
|
|
|
|
successMetric: input.successMetric,
|
2026-08-25 17:20:53 -04:00
|
|
|
planSlug: input.planSlug,
|
|
|
|
|
epicSlug: input.epicSlug,
|
|
|
|
|
backlogItemId: input.backlogItemId,
|
|
|
|
|
repoPath: input.repoPath,
|
|
|
|
|
briefFiles: input.briefFiles ?? (input.repoPath ? [input.repoPath] : undefined),
|
2026-07-07 16:01:03 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [created] = await ctx.db
|
|
|
|
|
.insert(objects)
|
|
|
|
|
.values({
|
|
|
|
|
type: "project",
|
|
|
|
|
title: input.slug,
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
description: input.ideaMarkdown,
|
|
|
|
|
status: "draft",
|
|
|
|
|
content,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!created) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: "Failed to create convoy.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await insertConvoyEvent(ctx.db, {
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
convoyId: created.id,
|
|
|
|
|
role: "role-conductor",
|
|
|
|
|
actor: "role-conductor",
|
|
|
|
|
eventType: "status_change",
|
|
|
|
|
metadata: { from: null, to: "draft", source: "mission_control" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { convoyId: created.id };
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
gateAction: workspaceProcedure.input(gateActionSchema).mutation(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = (convoy.status ?? "draft") as ConvoyStatus;
|
|
|
|
|
const { actor, toStatus } = resolveGateTransition(status, input.action);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await transitionConvoy(ctx.db, {
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
convoyId: input.convoyId,
|
|
|
|
|
toStatus,
|
|
|
|
|
actor,
|
|
|
|
|
reason: input.reason,
|
|
|
|
|
});
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ConvoyTransitionError) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "BAD_REQUEST",
|
|
|
|
|
message: error.message,
|
|
|
|
|
cause: { code: error.code },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
gateCopilot: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
question: z.string().min(1).max(4_000).optional(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = convoyObjectContentSchema.safeParse(convoy.content);
|
|
|
|
|
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),
|
|
|
|
|
convoyPolicy: content.success ? content.data.modelPolicy : undefined,
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = convoy.status ?? "draft";
|
|
|
|
|
const system = [
|
|
|
|
|
"You are the gate co-pilot for an agent-pipeline convoy.",
|
|
|
|
|
"Summarize the plan, highlight risks, and recommend approve / revise / request changes.",
|
|
|
|
|
"Be concise and actionable.",
|
|
|
|
|
"",
|
|
|
|
|
`Convoy: ${content.success ? content.data.convoySlug : convoy.title}`,
|
|
|
|
|
`Status: ${status}`,
|
|
|
|
|
content.success ? `Classification: ${content.data.classification}` : "",
|
|
|
|
|
content.success ? `Success metric: ${content.data.successMetric}` : "",
|
|
|
|
|
convoy.description ? `Plan:\n${convoy.description}` : "",
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
|
|
|
|
const userPrompt =
|
|
|
|
|
input.question?.trim() ||
|
|
|
|
|
"Summarize this convoy for the operator and suggest the best gate action.";
|
|
|
|
|
|
|
|
|
|
const { text } = await generateText({
|
|
|
|
|
model: modelResult.model,
|
|
|
|
|
system,
|
|
|
|
|
prompt: userPrompt,
|
|
|
|
|
temperature: 0.4,
|
|
|
|
|
maxTokens: 1_500,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
text: text.trim(),
|
|
|
|
|
resolvedAlias: resolved.resolvedAlias,
|
|
|
|
|
modelId: resolved.model.modelId,
|
|
|
|
|
providerType: resolved.model.providerType,
|
|
|
|
|
};
|
|
|
|
|
}),
|
2026-08-25 17:20:53 -04:00
|
|
|
|
2026-08-25 17:56:02 -04:00
|
|
|
postOperatorNote: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
briefId: z.string().uuid().nullable().optional(),
|
|
|
|
|
delivery: z.enum(["queued", "direct"]),
|
|
|
|
|
body: z.string().min(1).max(2_000),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const authorUserId = ctx.session.user.id;
|
|
|
|
|
|
|
|
|
|
const [row] = await ctx.db
|
|
|
|
|
.insert(convoyOperatorMessages)
|
|
|
|
|
.values({
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
convoyId: input.convoyId,
|
|
|
|
|
briefId: input.briefId ?? null,
|
|
|
|
|
delivery: input.delivery,
|
|
|
|
|
body: input.body,
|
|
|
|
|
authorUserId,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!row) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: "Failed to create operator note.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
listOperatorNotes: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
briefId: z.string().uuid().nullable().optional(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows = await ctx.db
|
|
|
|
|
.select()
|
|
|
|
|
.from(convoyOperatorMessages)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(convoyOperatorMessages.workspaceId, ctx.workspace.id),
|
|
|
|
|
eq(convoyOperatorMessages.convoyId, input.convoyId),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.orderBy(asc(convoyOperatorMessages.createdAt));
|
|
|
|
|
|
|
|
|
|
void input.briefId;
|
|
|
|
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
workspaceId: r.workspaceId,
|
|
|
|
|
convoyId: r.convoyId,
|
|
|
|
|
briefId: r.briefId,
|
|
|
|
|
delivery: r.delivery,
|
|
|
|
|
body: r.body,
|
|
|
|
|
authorUserId: r.authorUserId,
|
|
|
|
|
deliveredAt: r.deliveredAt ? r.deliveredAt.toISOString() : null,
|
|
|
|
|
createdAt: r.createdAt.toISOString(),
|
|
|
|
|
}));
|
|
|
|
|
}),
|
|
|
|
|
|
2026-08-25 18:49:38 -04:00
|
|
|
complete: workspaceProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
workspace: z.string().min(1),
|
|
|
|
|
convoyId: z.string().uuid(),
|
|
|
|
|
screenshotUrl: z.string().url().nullable().optional(),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const convoy = await loadConvoyProject(ctx.db, ctx.workspace.id, input.convoyId);
|
|
|
|
|
if (!convoy) {
|
|
|
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "Convoy not found." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = (convoy.status ?? "draft") as ConvoyStatus;
|
|
|
|
|
if (status !== "merged" && status !== "published") {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "BAD_REQUEST",
|
|
|
|
|
message: `Convoy must be in merged or published to complete, currently ${status}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const contentParsed = convoyObjectContentSchema.safeParse(convoy.content);
|
|
|
|
|
const briefs = await getConvoyBriefs(ctx.db, ctx.workspace.id, convoy.id);
|
|
|
|
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, convoy.id);
|
|
|
|
|
const events = await getRecentConvoyEvents(ctx.db, ctx.workspace.id, convoy.id, 30);
|
|
|
|
|
|
|
|
|
|
let modelAlias: string | null = null;
|
|
|
|
|
let harnessAlias: string | null = null;
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
const maybeModel = (ev as Record<string, unknown>).modelAlias as string | null | undefined;
|
|
|
|
|
const maybeHarness = (ev as Record<string, unknown>).harnessAlias as string | null | undefined;
|
|
|
|
|
if (!modelAlias && typeof maybeModel === "string" && maybeModel) modelAlias = maybeModel;
|
|
|
|
|
if (!harnessAlias && typeof maybeHarness === "string" && maybeHarness) harnessAlias = maybeHarness;
|
|
|
|
|
if (modelAlias && harnessAlias) break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const slug = contentParsed.success ? contentParsed.data.convoySlug : convoy.title;
|
|
|
|
|
const classification = contentParsed.success
|
|
|
|
|
? contentParsed.data.classification
|
|
|
|
|
: ("feature" as const);
|
|
|
|
|
const successMetric = contentParsed.success
|
|
|
|
|
? contentParsed.data.successMetric
|
|
|
|
|
: "No success metric captured";
|
|
|
|
|
|
|
|
|
|
const summaryMd = generateCompletionSummary({
|
|
|
|
|
slug,
|
|
|
|
|
classification,
|
|
|
|
|
successMetric,
|
|
|
|
|
briefs: briefs.map((b) => ({ title: b.title, status: b.status })),
|
|
|
|
|
modelAlias,
|
|
|
|
|
harnessAlias,
|
|
|
|
|
prUrl: pr.prUrl,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let result: Awaited<ReturnType<typeof transitionConvoy>>;
|
|
|
|
|
try {
|
|
|
|
|
result = await transitionConvoy(ctx.db, {
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
convoyId: input.convoyId,
|
|
|
|
|
toStatus: "released",
|
|
|
|
|
actor: "human-gate-3",
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof ConvoyTransitionError) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "BAD_REQUEST",
|
|
|
|
|
message: error.message,
|
|
|
|
|
cause: { code: error.code },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [artifact] = await ctx.db
|
|
|
|
|
.insert(completionArtifacts)
|
|
|
|
|
.values({
|
|
|
|
|
workspaceId: ctx.workspace.id,
|
|
|
|
|
convoyId: convoy.id,
|
|
|
|
|
summaryMd,
|
|
|
|
|
screenshotUrl: input.screenshotUrl ?? null,
|
|
|
|
|
createdBy: ctx.session.user.id,
|
|
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
|
|
|
|
if (!artifact) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
|
|
|
message: "Failed to create completion artifact.",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
convoy: result.convoy,
|
|
|
|
|
artifact: {
|
|
|
|
|
id: artifact.id,
|
|
|
|
|
workspaceId: artifact.workspaceId,
|
|
|
|
|
convoyId: artifact.convoyId,
|
|
|
|
|
summaryMd: artifact.summaryMd,
|
|
|
|
|
screenshotUrl: artifact.screenshotUrl,
|
|
|
|
|
createdBy: artifact.createdBy,
|
|
|
|
|
createdAt: artifact.createdAt.toISOString(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
listArtifacts: workspaceProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const rows = await ctx.db
|
|
|
|
|
.select({
|
|
|
|
|
artifact: completionArtifacts,
|
|
|
|
|
convoyTitle: objects.title,
|
|
|
|
|
convoyStatus: objects.status,
|
|
|
|
|
convoyContent: objects.content,
|
|
|
|
|
})
|
|
|
|
|
.from(completionArtifacts)
|
|
|
|
|
.leftJoin(objects, eq(objects.id, completionArtifacts.convoyId))
|
|
|
|
|
.where(eq(completionArtifacts.workspaceId, ctx.workspace.id))
|
|
|
|
|
.orderBy(desc(completionArtifacts.createdAt));
|
|
|
|
|
|
|
|
|
|
const enriched = await Promise.all(
|
|
|
|
|
rows.map(async (row) => {
|
|
|
|
|
const pr = await getConvoyPrUrl(ctx.db, ctx.workspace.id, row.artifact.convoyId);
|
|
|
|
|
const events = await getRecentConvoyEvents(
|
|
|
|
|
ctx.db,
|
|
|
|
|
ctx.workspace.id,
|
|
|
|
|
row.artifact.convoyId,
|
|
|
|
|
20,
|
|
|
|
|
);
|
|
|
|
|
const content = convoyObjectContentSchema.safeParse(row.convoyContent);
|
|
|
|
|
return {
|
|
|
|
|
id: row.artifact.id,
|
|
|
|
|
workspaceId: row.artifact.workspaceId,
|
|
|
|
|
convoyId: row.artifact.convoyId,
|
|
|
|
|
convoyTitle: row.convoyTitle ?? "Untitled",
|
|
|
|
|
convoySlug: content.success ? content.data.convoySlug : (row.convoyTitle ?? ""),
|
|
|
|
|
convoyStatus: row.convoyStatus ?? "released",
|
|
|
|
|
summaryMd: row.artifact.summaryMd,
|
|
|
|
|
screenshotUrl: row.artifact.screenshotUrl,
|
|
|
|
|
createdBy: row.artifact.createdBy,
|
|
|
|
|
createdAt: row.artifact.createdAt.toISOString(),
|
|
|
|
|
pr,
|
|
|
|
|
timeline: events.map((ev) => ({
|
|
|
|
|
id: ev.id,
|
|
|
|
|
eventType: ev.eventType,
|
|
|
|
|
role: ev.role,
|
|
|
|
|
actor: ev.actor,
|
|
|
|
|
occurredAt: ev.occurredAt.toISOString(),
|
|
|
|
|
metadata: ev.metadata,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return enriched;
|
|
|
|
|
}),
|
|
|
|
|
|
2026-08-25 17:20:53 -04:00
|
|
|
fleet: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const userId = ctx.session.user.id;
|
|
|
|
|
|
|
|
|
|
const memberRows = await ctx.db
|
|
|
|
|
.select({ workspaceId: workspaceMembers.workspaceId })
|
|
|
|
|
.from(workspaceMembers)
|
|
|
|
|
.where(eq(workspaceMembers.userId, userId));
|
|
|
|
|
|
|
|
|
|
const ownedRows = await ctx.db
|
|
|
|
|
.select({ id: workspaces.id })
|
|
|
|
|
.from(workspaces)
|
|
|
|
|
.where(eq(workspaces.ownerUserId, userId));
|
|
|
|
|
|
|
|
|
|
const idSet = new Set<string>([
|
|
|
|
|
...memberRows.map((r) => r.workspaceId),
|
|
|
|
|
...ownedRows.map((r) => r.id),
|
|
|
|
|
]);
|
|
|
|
|
const workspaceIds = [...idSet];
|
|
|
|
|
|
|
|
|
|
if (workspaceIds.length === 0) {
|
|
|
|
|
return { byWorkspace: [], byStatus: {}, gateInboxAcross: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wsRows = await ctx.db
|
|
|
|
|
.select({ id: workspaces.id, slug: workspaces.slug, name: workspaces.name })
|
|
|
|
|
.from(workspaces)
|
|
|
|
|
.where(inArray(workspaces.id, workspaceIds));
|
|
|
|
|
|
|
|
|
|
const byWorkspace: Array<{
|
|
|
|
|
workspace: { id: string; slug: string; name: string };
|
|
|
|
|
convoys: Array<Awaited<ReturnType<typeof listConvoyProjects>>[number] & { workspaceId: string; workspaceSlug: string; workspaceName: string }>;
|
|
|
|
|
}> = [];
|
|
|
|
|
const allConvoys: Array<Awaited<ReturnType<typeof listConvoyProjects>>[number] & { workspaceId: string; workspaceSlug: string; workspaceName: string }> = [];
|
|
|
|
|
|
|
|
|
|
for (const ws of wsRows) {
|
|
|
|
|
const convoys = await listConvoyProjects(ctx.db, ws.id);
|
|
|
|
|
const enriched = convoys.map((c) => ({
|
|
|
|
|
...c,
|
|
|
|
|
workspaceId: ws.id,
|
|
|
|
|
workspaceSlug: ws.slug,
|
|
|
|
|
workspaceName: ws.name,
|
|
|
|
|
}));
|
|
|
|
|
byWorkspace.push({ workspace: ws, convoys: enriched });
|
|
|
|
|
allConvoys.push(...enriched);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byStatus: Record<string, typeof allConvoys> = {};
|
|
|
|
|
for (const convoy of allConvoys) {
|
|
|
|
|
const key = convoy.status;
|
|
|
|
|
if (!byStatus[key]) byStatus[key] = [];
|
|
|
|
|
byStatus[key].push(convoy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gateInboxAcross = allConvoys.filter((c) =>
|
|
|
|
|
(GATE_INBOX_STATUSES as readonly string[]).includes(c.status),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { byWorkspace, byStatus, gateInboxAcross };
|
|
|
|
|
}),
|
2026-07-07 16:01:03 -04:00
|
|
|
});
|