feat(mission-control): convoy kanban, gate inbox, and co-pilot (Epic 5)
Adds mission control UI with human gate actions via tRPC, kanban columns from
the shared lifecycle enum, and gate co-pilot using resolveModel('gate-copilot').
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4bb553c15f
commit
ea70716fc6
15 changed files with 1212 additions and 5 deletions
72
apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx
Normal file
72
apps/web/app/(app)/[workspaceSlug]/mission-control/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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={() =>
|
||||||
|
|
|
||||||
181
apps/web/components/mission-control/convoy-detail-panel.tsx
Normal file
181
apps/web/components/mission-control/convoy-detail-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/components/mission-control/convoy-kanban.tsx
Normal file
128
apps/web/components/mission-control/convoy-kanban.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
apps/web/components/mission-control/create-convoy-dialog.tsx
Normal file
171
apps/web/components/mission-control/create-convoy-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
apps/web/server/lib/convoy/convoy-service.ts
Normal file
198
apps/web/server/lib/convoy/convoy-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
338
apps/web/server/routers/convoys.ts
Normal file
338
apps/web/server/routers/convoys.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
51
packages/shared/src/convoy-lifecycle/kanban.ts
Normal file
51
packages/shared/src/convoy-lifecycle/kanban.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue