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:
commit
5025ac5266
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,
|
||||
MoreHorizontal,
|
||||
Presentation,
|
||||
Radar,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
Users,
|
||||
|
|
@ -34,6 +35,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{ id: "home", label: "Home", segment: "", icon: Home },
|
||||
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
|
||||
{ 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: "teams", label: "Teams", segment: "teams", icon: Users },
|
||||
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
|
||||
|
|
@ -47,6 +49,7 @@ function workspaceHref(workspaceSegment: string, segment: string) {
|
|||
|
||||
const RESERVED_ROOT_SEGMENTS = new Set([
|
||||
"planner",
|
||||
"mission-control",
|
||||
"ai",
|
||||
"teams",
|
||||
"docs",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Pencil,
|
||||
Plus,
|
||||
Presentation,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
|
|
@ -164,6 +165,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
Role routing
|
||||
</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
|
||||
className="gap-2.5 px-4"
|
||||
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 { backlogRouter } from "@/server/routers/backlog";
|
||||
import { connectedReposRouter } from "@/server/routers/connected-repos";
|
||||
import { convoysRouter } from "@/server/routers/convoys";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -35,6 +36,7 @@ export const appRouter = router({
|
|||
runs: runsRouter,
|
||||
backlog: backlogRouter,
|
||||
connectedRepos: connectedReposRouter,
|
||||
convoys: convoysRouter,
|
||||
});
|
||||
|
||||
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,
|
||||
validateConvoyTransition,
|
||||
} 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
|
||||
title: Mission control — convoy kanban, gate inbox, co-pilot
|
||||
plan_slug: automation-foundation
|
||||
status: draft
|
||||
status: done
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
cursor_epic_id: null
|
||||
|
|
@ -41,9 +41,10 @@ Echodo UI for human gates and convoy visibility:
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Operator sees pending Gate 1 convoy and approves → status `ready`.
|
||||
- [ ] Co-pilot uses `claude-planning` alias when configured.
|
||||
- [x] Operator sees pending Gate 1 convoy and approves → status `ready`.
|
||||
- [x] Co-pilot uses `claude-planning` alias when configured.
|
||||
|
||||
## 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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue