feat(mission-control): convoy kanban, gate inbox, co-pilot (Epic 5)

feat(mission-control): convoy kanban, gate inbox, co-pilot (Epic 5)
This commit is contained in:
varutasu 2026-07-07 15:16:13 -05:00 committed by GitHub
commit 5025ac5266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1212 additions and 5 deletions

View file

@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import { Plus, Radar } from "lucide-react";
import { ConvoyDetailPanel } from "@/components/mission-control/convoy-detail-panel";
import { ConvoyKanban, GateInbox } from "@/components/mission-control/convoy-kanban";
import { CreateConvoyDialog } from "@/components/mission-control/create-convoy-dialog";
import { Button } from "@/components/ui/button";
export default function MissionControlPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
if (!workspaceSlug) {
return null;
}
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Radar className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">Mission control</h1>
<p className="text-xs text-muted-foreground">
Convoy kanban, human gates, and gate co-pilot
</p>
</div>
</div>
<Button type="button" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 size-4" />
New convoy
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[220px_1fr_320px]">
<aside className="min-h-0 overflow-y-auto border-r border-border bg-muted/10">
<GateInbox
workspaceSlug={workspaceSlug}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</aside>
<main className="flex min-h-0 flex-col overflow-hidden">
<ConvoyKanban
workspaceSlug={workspaceSlug}
selectedId={selectedId}
onSelect={setSelectedId}
/>
</main>
<aside className="min-h-0 border-l border-border bg-background">
<ConvoyDetailPanel workspaceSlug={workspaceSlug} convoyId={selectedId} />
</aside>
</div>
<CreateConvoyDialog
workspaceSlug={workspaceSlug}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={setSelectedId}
/>
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
LayoutGrid, LayoutGrid,
MoreHorizontal, MoreHorizontal,
Presentation, Presentation,
Radar,
Sparkles, Sparkles,
UserPlus, UserPlus,
Users, Users,
@ -34,6 +35,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home }, { id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid }, { id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays }, { id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{ id: "mission-control", label: "Mission control", segment: "mission-control", icon: Radar },
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles }, { id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "teams", label: "Teams", segment: "teams", icon: Users }, { id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText }, { id: "docs", label: "Docs", segment: "docs", icon: FileText },
@ -47,6 +49,7 @@ function workspaceHref(workspaceSegment: string, segment: string) {
const RESERVED_ROOT_SEGMENTS = new Set([ const RESERVED_ROOT_SEGMENTS = new Set([
"planner", "planner",
"mission-control",
"ai", "ai",
"teams", "teams",
"docs", "docs",

View file

@ -17,6 +17,7 @@ import {
Pencil, Pencil,
Plus, Plus,
Presentation, Presentation,
Radar,
ScrollText, ScrollText,
Search, Search,
Settings, Settings,
@ -164,6 +165,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
<Sparkles className="size-4 text-muted-foreground" /> <Sparkles className="size-4 text-muted-foreground" />
Role routing Role routing
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspace?.slug && router.push(`/${workspace.slug}/mission-control`)
}
>
<Radar className="size-4 text-muted-foreground" />
Mission control
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="gap-2.5 px-4" className="gap-2.5 px-4"
onSelect={() => onSelect={() =>

View file

@ -0,0 +1,181 @@
"use client";
import * as React from "react";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
function gateActionsForStatus(status: ConvoyStatus): Array<{
action: "approve" | "revise" | "request_changes" | "promote";
label: string;
variant?: "default" | "outline" | "destructive";
}> {
switch (status) {
case "convoy_review":
return [
{ action: "approve", label: "Approve plan" },
{ action: "revise", label: "Request changes", variant: "outline" },
];
case "pr_review":
return [
{ action: "approve", label: "Approve merge" },
{ action: "request_changes", label: "Request changes", variant: "outline" },
];
case "merged":
return [{ action: "promote", label: "Promote to prod" }];
default:
return [];
}
}
export function ConvoyDetailPanel({
workspaceSlug,
convoyId,
}: {
workspaceSlug: string;
convoyId: string | null;
}) {
const utils = api.useUtils();
const [copilotText, setCopilotText] = React.useState<string | null>(null);
const detailQuery = api.convoys.get.useQuery(
{ workspace: workspaceSlug, convoyId: convoyId ?? "" },
{ enabled: Boolean(workspaceSlug && convoyId) },
);
const gateMut = api.convoys.gateAction.useMutation({
onSuccess: () => {
void utils.convoys.kanban.invalidate();
if (convoyId) void utils.convoys.get.invalidate({ workspace: workspaceSlug, convoyId });
},
});
const copilotMut = api.convoys.gateCopilot.useMutation({
onSuccess: (result) => setCopilotText(result.text),
});
if (!convoyId) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a convoy to review gates and co-pilot suggestions.
</div>
);
}
if (detailQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading convoy…
</div>
);
}
const convoy = detailQuery.data?.convoy;
if (!convoy) {
return <p className="p-6 text-sm text-muted-foreground">Convoy not found.</p>;
}
const status = convoy.status as ConvoyStatus;
const actions = gateActionsForStatus(status);
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-4">
<div className="mb-4">
<h2 className="text-lg font-semibold">
{convoy.content?.convoySlug ?? convoy.title}
</h2>
<p className="text-xs text-muted-foreground">Status: {status}</p>
{convoy.content?.successMetric && (
<p className="mt-2 text-sm text-muted-foreground">{convoy.content.successMetric}</p>
)}
</div>
{convoy.description && (
<div className="mb-4 rounded-md border border-border bg-muted/30 p-3">
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Plan</p>
<pre className="whitespace-pre-wrap text-sm">{convoy.description}</pre>
</div>
)}
{actions.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{actions.map((item) => (
<Button
key={item.action}
type="button"
size="sm"
variant={item.variant ?? "default"}
disabled={gateMut.isPending}
onClick={() =>
gateMut.mutate({
workspace: workspaceSlug,
convoyId,
action: item.action,
})
}
>
{gateMut.isPending ? <Loader2 className="size-4 animate-spin" /> : item.label}
</Button>
))}
</div>
)}
<div className="mb-4 rounded-md border border-border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex items-center gap-1 text-sm font-medium">
<Sparkles className="size-4 text-primary" />
Gate co-pilot
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={copilotMut.isPending}
onClick={() =>
copilotMut.mutate({
workspace: workspaceSlug,
convoyId,
})
}
>
{copilotMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Summarize"
)}
</Button>
</div>
{copilotMut.data && (
<p className="mb-2 text-[10px] text-muted-foreground">
Model: {copilotMut.data.resolvedAlias} ({copilotMut.data.modelId})
</p>
)}
{copilotMut.error && (
<p className="text-xs text-destructive">{copilotMut.error.message}</p>
)}
{copilotText && (
<div className="prose prose-sm dark:prose-invert max-w-none text-sm">
<p className="whitespace-pre-wrap">{copilotText}</p>
</div>
)}
</div>
{(detailQuery.data?.briefs.length ?? 0) > 0 && (
<div>
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">Briefs</p>
<ul className="space-y-1 text-sm">
{detailQuery.data?.briefs.map((brief) => (
<li key={brief.id} className="rounded border border-border px-2 py-1">
{brief.title}{" "}
<span className="text-muted-foreground">({brief.status ?? "—"})</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,128 @@
"use client";
import * as React from "react";
import { gateLabelForStatus } from "@tasks/shared/convoy-lifecycle";
import type { ConvoyStatus } from "@tasks/shared/convoy-lifecycle";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
type ConvoyCard = {
id: string;
title: string;
status: ConvoyStatus;
content: { convoySlug: string; classification: string; successMetric: string } | null;
};
export function ConvoyKanban({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
if (kanbanQuery.isLoading) {
return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading kanban…
</div>
);
}
const columns = kanbanQuery.data?.columns ?? [];
return (
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{columns.map((column) => (
<div
key={column.status}
className="flex w-56 shrink-0 flex-col rounded-lg border border-border bg-muted/20"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{column.label}
</p>
<p className="text-[10px] text-muted-foreground">{column.convoys.length}</p>
</div>
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
{column.convoys.map((convoy) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
className={cn(
"w-full rounded-md border border-border bg-background p-2 text-left text-sm transition-colors hover:bg-muted",
selectedId === convoy.id && "ring-2 ring-primary",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
{convoy.content?.classification && (
<p className="mt-0.5 text-[10px] text-muted-foreground">
{convoy.content.classification}
</p>
)}
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export function GateInbox({
workspaceSlug,
selectedId,
onSelect,
}: {
workspaceSlug: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const kanbanQuery = api.convoys.kanban.useQuery(
{ workspace: workspaceSlug },
{ enabled: Boolean(workspaceSlug) },
);
const inbox = kanbanQuery.data?.gateInbox ?? [];
return (
<div className="border-b border-border p-4">
<h2 className="text-sm font-semibold">Needs you</h2>
<p className="text-xs text-muted-foreground">Gate 1, 2, and 3 approvals</p>
{inbox.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">No pending gates.</p>
) : (
<ul className="mt-3 space-y-2">
{inbox.map((convoy: ConvoyCard) => (
<li key={convoy.id}>
<button
type="button"
onClick={() => onSelect(convoy.id)}
className={cn(
"w-full rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-left text-sm hover:bg-amber-500/10",
selectedId === convoy.id && "ring-2 ring-amber-500",
)}
>
<p className="font-medium">{convoy.content?.convoySlug ?? convoy.title}</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
{gateLabelForStatus(convoy.status)}
</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Loader2, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { ConvoyClassification } from "@tasks/shared/convoy-lifecycle";
const CLASSIFICATIONS: ConvoyClassification[] = [
"feature",
"hotfix",
"docs-only",
"infra-only",
"server-only",
"config-only",
];
type FormState = {
slug: string;
classification: ConvoyClassification;
successMetric: string;
ideaMarkdown: string;
};
const EMPTY: FormState = {
slug: "",
classification: "feature",
successMetric: "",
ideaMarkdown: "",
};
export function CreateConvoyDialog({
workspaceSlug,
open,
onOpenChange,
onCreated,
}: {
workspaceSlug: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (convoyId: string) => void;
}) {
const utils = api.useUtils();
const [form, setForm] = React.useState<FormState>(EMPTY);
const createMut = api.convoys.create.useMutation({
onSuccess: (result) => {
void utils.convoys.kanban.invalidate();
onCreated(result.convoyId);
onOpenChange(false);
setForm(EMPTY);
},
});
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50" />
<DialogPrimitive.Content className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
New convoy
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded p-1 hover:bg-muted" aria-label="Close">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-slug">
Slug
</label>
<Input
id="convoy-slug"
placeholder="my-feature"
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-class">
Classification
</label>
<select
id="convoy-class"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.classification}
onChange={(e) =>
setForm((f) => ({
...f,
classification: e.target.value as ConvoyClassification,
}))
}
>
{CLASSIFICATIONS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-metric">
Success metric
</label>
<Input
id="convoy-metric"
value={form.successMetric}
onChange={(e) => setForm((f) => ({ ...f, successMetric: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium" htmlFor="convoy-idea">
Idea / plan markdown
</label>
<textarea
id="convoy-idea"
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.ideaMarkdown}
onChange={(e) => setForm((f) => ({ ...f, ideaMarkdown: e.target.value }))}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<DialogPrimitive.Close asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogPrimitive.Close>
<Button
type="button"
disabled={
createMut.isPending ||
!form.slug.trim() ||
!form.successMetric.trim() ||
!form.ideaMarkdown.trim()
}
onClick={() =>
createMut.mutate({
workspace: workspaceSlug,
slug: form.slug.trim(),
classification: form.classification,
successMetric: form.successMetric.trim(),
ideaMarkdown: form.ideaMarkdown.trim(),
})
}
>
{createMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Plus className="mr-1 size-4" />
Create
</>
)}
</Button>
</div>
{createMut.error && (
<p className="text-xs text-destructive">{createMut.error.message}</p>
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,198 @@
import { and, desc, eq, sql } from "drizzle-orm";
import type { db as dbInstance } from "@tasks/database";
import { convoyEvents, objects } from "@tasks/database/schema";
import {
convoyObjectContentSchema,
convoyStatusSchema,
validateConvoyTransition,
type ConvoyObjectContent,
type ConvoyStatus,
} from "@tasks/shared/convoy-lifecycle";
type Db = typeof dbInstance;
type ObjectRow = typeof objects.$inferSelect;
export type ConvoySummary = {
id: string;
title: string;
status: ConvoyStatus;
description: string | null;
content: ConvoyObjectContent | null;
updatedAt: Date;
};
function parseStatus(raw: string | null): ConvoyStatus {
const parsed = convoyStatusSchema.safeParse(raw ?? "draft");
return parsed.success ? parsed.data : "draft";
}
function toConvoySummary(row: ObjectRow): ConvoySummary {
const contentParsed = convoyObjectContentSchema.safeParse(row.content);
return {
id: row.id,
title: row.title,
status: parseStatus(row.status),
description: row.description,
content: contentParsed.success ? contentParsed.data : null,
updatedAt: row.updatedAt,
};
}
export function convoyProjectsFilter(workspaceId: string) {
return and(
eq(objects.workspaceId, workspaceId),
eq(objects.type, "project"),
sql`${objects.content} ->> 'convoySlug' IS NOT NULL`,
);
}
export async function listConvoyProjects(db: Db, workspaceId: string): Promise<ConvoySummary[]> {
const rows = await db
.select()
.from(objects)
.where(convoyProjectsFilter(workspaceId))
.orderBy(desc(objects.updatedAt));
return rows
.map(toConvoySummary)
.filter((row) => row.content !== null && row.status !== "cancelled");
}
export async function findConvoyBySlug(db: Db, workspaceId: string, slug: string) {
const [row] = await db
.select()
.from(objects)
.where(
and(
convoyProjectsFilter(workspaceId),
sql`${objects.content} ->> 'convoySlug' = ${slug}`,
),
)
.limit(1);
return row ?? null;
}
export async function loadConvoyProject(db: Db, workspaceId: string, convoyId: string) {
const [row] = await db
.select()
.from(objects)
.where(
and(
eq(objects.id, convoyId),
convoyProjectsFilter(workspaceId),
),
)
.limit(1);
return row ?? null;
}
export async function insertConvoyEvent(
db: Db,
input: {
workspaceId: string;
convoyId: string;
briefId?: string | null;
role: string;
actor?: string | null;
eventType: string;
metadata?: Record<string, unknown> | null;
},
) {
const [row] = await db
.insert(convoyEvents)
.values({
workspaceId: input.workspaceId,
convoyId: input.convoyId,
briefId: input.briefId ?? null,
role: input.role,
actor: input.actor ?? null,
eventType: input.eventType,
metadata: input.metadata ?? null,
})
.returning();
return row;
}
export async function transitionConvoy(
db: Db,
input: {
workspaceId: string;
convoyId: string;
toStatus: ConvoyStatus;
actor: string;
reason?: string;
},
): Promise<{ convoy: ConvoySummary; from: ConvoyStatus; to: ConvoyStatus }> {
const convoy = await loadConvoyProject(db, input.workspaceId, input.convoyId);
if (!convoy) {
throw new Error("Convoy not found");
}
const fromStatus = parseStatus(convoy.status);
validateConvoyTransition({
from: fromStatus,
to: input.toStatus,
actor: input.actor,
reason: input.reason,
});
const [updated] = await db
.update(objects)
.set({
status: input.toStatus,
updatedAt: new Date(),
})
.where(eq(objects.id, convoy.id))
.returning();
if (!updated) {
throw new Error("Failed to update convoy status");
}
await insertConvoyEvent(db, {
workspaceId: input.workspaceId,
convoyId: convoy.id,
role: input.actor,
actor: input.actor,
eventType: "status_change",
metadata: {
from: fromStatus,
to: input.toStatus,
reason: input.reason ?? null,
},
});
return {
convoy: toConvoySummary(updated),
from: fromStatus,
to: input.toStatus,
};
}
export async function getConvoyBriefs(db: Db, workspaceId: string, convoyId: string) {
return db
.select({
id: objects.id,
title: objects.title,
status: objects.status,
sortOrder: objects.sortOrder,
})
.from(objects)
.where(and(eq(objects.parentId, convoyId), eq(objects.workspaceId, workspaceId)))
.orderBy(objects.sortOrder);
}
export async function getRecentConvoyEvents(
db: Db,
workspaceId: string,
convoyId: string,
limit: number,
) {
return db
.select()
.from(convoyEvents)
.where(and(eq(convoyEvents.convoyId, convoyId), eq(convoyEvents.workspaceId, workspaceId)))
.orderBy(desc(convoyEvents.occurredAt))
.limit(limit);
}

View file

@ -16,6 +16,7 @@ import { auditRouter } from "@/server/routers/audit";
import { runsRouter } from "@/server/routers/runs"; import { runsRouter } from "@/server/routers/runs";
import { backlogRouter } from "@/server/routers/backlog"; import { backlogRouter } from "@/server/routers/backlog";
import { connectedReposRouter } from "@/server/routers/connected-repos"; import { connectedReposRouter } from "@/server/routers/connected-repos";
import { convoysRouter } from "@/server/routers/convoys";
export const appRouter = router({ export const appRouter = router({
health: healthRouter, health: healthRouter,
@ -35,6 +36,7 @@ export const appRouter = router({
runs: runsRouter, runs: runsRouter,
backlog: backlogRouter, backlog: backlogRouter,
connectedRepos: connectedReposRouter, connectedRepos: connectedReposRouter,
convoys: convoysRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,338 @@
import { createLanguageModelFromResolved } from "@tasks/ai";
import { modelProviders, objects, roleModelRoutes } from "@tasks/database/schema";
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";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { asc, eq } from "drizzle-orm";
import { z } from "zod";
import {
findConvoyBySlug,
getConvoyBriefs,
getRecentConvoyEvents,
insertConvoyEvent,
listConvoyProjects,
loadConvoyProject,
transitionConvoy,
} 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 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" };
}
if (status === "merged" && action === "promote") {
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);
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,
};
}),
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),
}),
)
.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,
});
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,
};
}),
});

View file

@ -26,3 +26,11 @@ export {
mapFileStatusToServer, mapFileStatusToServer,
validateConvoyTransition, validateConvoyTransition,
} from "./state-machine"; } from "./state-machine";
export {
GATE_INBOX_STATUSES,
MISSION_CONTROL_KANBAN_COLUMNS,
gateLabelForStatus,
humanGateActorForStatus,
type KanbanColumn,
} from "./kanban";

View file

@ -0,0 +1,51 @@
import type { ConvoyStatus } from "./schemas";
export type KanbanColumn = {
status: ConvoyStatus;
label: string;
};
/** Kanban columns for mission control (Epic 5). */
export const MISSION_CONTROL_KANBAN_COLUMNS: KanbanColumn[] = [
{ status: "draft", label: "Planning" },
{ status: "convoy_review", label: "Awaiting plan approval" },
{ status: "ready", label: "Ready to build" },
{ status: "in_progress", label: "Building" },
{ status: "pr_review", label: "Awaiting merge" },
{ status: "merged", label: "Awaiting prod" },
{ status: "released", label: "Done" },
{ status: "blocked", label: "Blocked" },
];
/** Statuses surfaced in the human gate inbox. */
export const GATE_INBOX_STATUSES: ConvoyStatus[] = [
"convoy_review",
"pr_review",
"merged",
];
export function gateLabelForStatus(status: ConvoyStatus): string | null {
switch (status) {
case "convoy_review":
return "Gate 1 — Plan approval";
case "pr_review":
return "Gate 2 — PR merge";
case "merged":
return "Gate 3 — Prod promote";
default:
return null;
}
}
export function humanGateActorForStatus(status: ConvoyStatus): string | null {
switch (status) {
case "convoy_review":
return "human-gate-1";
case "pr_review":
return "human-gate-2";
case "merged":
return "human-gate-3";
default:
return null;
}
}

View file

@ -3,7 +3,7 @@ kind: epic
slug: mission-control-gates-ui slug: mission-control-gates-ui
title: Mission control — convoy kanban, gate inbox, co-pilot title: Mission control — convoy kanban, gate inbox, co-pilot
plan_slug: automation-foundation plan_slug: automation-foundation
status: draft status: done
priority: P1 priority: P1
tenant_id: global tenant_id: global
cursor_epic_id: null cursor_epic_id: null
@ -41,9 +41,10 @@ Echodo UI for human gates and convoy visibility:
## Acceptance criteria ## Acceptance criteria
- [ ] Operator sees pending Gate 1 convoy and approves → status `ready`. - [x] Operator sees pending Gate 1 convoy and approves → status `ready`.
- [ ] Co-pilot uses `claude-planning` alias when configured. - [x] Co-pilot uses `claude-planning` alias when configured.
## Tasks ## Tasks
_To be decomposed when epic graduates to `ready`._ - [x] [Task-trpc-convoys-router](./Task-trpc-convoys-router.md)
- [x] [Task-mission-control-ui](./Task-mission-control-ui.md)

View file

@ -0,0 +1,22 @@
---
kind: task
slug: mission-control-ui
title: Mission control page — kanban, gate inbox, detail panel
plan_slug: automation-foundation
epic_slug: mission-control-gates-ui
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-07"
---
# Task summary
`/{workspaceSlug}/mission-control` with three-column layout: needs-you inbox, kanban board, gate detail + co-pilot.
## Acceptance criteria
- [x] Operator can approve Gate 1 convoy from inbox → `ready`
- [x] Co-pilot shows resolved model alias from `gate-copilot` route

View file

@ -0,0 +1,22 @@
---
kind: task
slug: trpc-convoys-router
title: convoys tRPC router — kanban, gates, create, co-pilot
plan_slug: automation-foundation
epic_slug: mission-control-gates-ui
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-07"
---
# Task summary
Web tRPC wrapper over convoy lifecycle: kanban grouping, gate inbox, gate actions, create convoy, gate co-pilot via `resolveModel('gate-copilot')`.
## Acceptance criteria
- [x] `api.convoys.kanban`, `gateAction`, `create`, `gateCopilot`
- [x] Gate actions map to `human-gate-1/2/3` actors

View file

@ -166,7 +166,7 @@ flowchart LR
| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | done | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) | | 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | done | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) |
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — | | 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — |
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 | | 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 |
| 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 | | 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | done | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 |
| 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 | | 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 |
| 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 | | 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 |
| 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 | | 8 | [Forge webhooks](./Epic-forge-webhooks/Epic-forge-webhooks.md) | draft | PR opened/merged/CI webhooks → convoy transitions | 3, 4, 7 |