feat(automation): model provider registry (Epic 1)

feat(automation): model provider registry (Epic 1)
This commit is contained in:
varutasu 2026-07-07 14:17:11 -05:00 committed by GitHub
commit 2d309e8a75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 10364 additions and 5 deletions

View file

@ -20,6 +20,12 @@
# Production (Coolify, CT 102): dedicated `echodo` user/db on shared Postgres
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
# UUID of the workspace that owns imported `plans/` rows (slug or UUID from Settings).
# For dogfooding this repo, use the `convoys-tasks` workspace id.
# Required by `pnpm --filter @tasks/database import:markdown-backlog` and
# `watch:markdown-backlog` (not loaded automatically — set in `.env`).
MARKDOWN_BACKLOG_WORKSPACE_ID="00000000-0000-0000-0000-000000000000"
# ----- Redis (CT 102 shared Redis) -----
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
# Local dev: redis://localhost:6379
@ -70,11 +76,25 @@ NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
DIRECTUS_FEEDBACK_TOKEN=""
# ----- AI Providers -----
# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
# local Ollama (CT 108) instead of the OpenAI API.
# Registry aliases (Epic 1) read these env var NAMES from the DB — values
# live here on the server/worker host, never in Postgres.
#
# Local Ollama on Mac:
# OPENAI_BASE_URL=http://<mac-lan-ip>:11434/v1
# OPENAI_API_KEY=ollama
# OPENAI_MODEL=qwen2.5-coder:32b
#
# Local MLX (mlx-openai-server on Mac — preferred for parallel implementers):
# MLX_SERVER_URL=http://<mac-lan-ip>:8000/v1
# MLX_API_KEY=local
# (Register as openai_compatible alias qwen-coder-mlx in Settings when Epic 1 ships)
#
# Cloud / free-tier (see docs/adr-model-routing.md):
OPENAI_API_KEY=""
OPENAI_BASE_URL=""
ANTHROPIC_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY=""
NVIDIA_API_KEY=""
# ----- MCP Server -----
MCP_SERVER_PORT=3001

View file

@ -1,5 +1,6 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { modelTierSchema, providerTypeSchema } from "@tasks/shared/model-routing";
import { db } from "../db.js";
import { eq } from "../drizzle.js";
import { agentRuns, auditLog, markdownBacklogItems } from "../schema.js";
@ -44,6 +45,15 @@ const completeTaskInputSchema = z.object({
.describe(
"Late-bound model handle. Same merge semantics as `client` — pass this when the model that closed the session differs from the one that opened it.",
),
modelAlias: z
.string()
.min(1)
.max(120)
.optional()
.describe("Registry alias used for this session (e.g. qwen-coder)."),
modelTier: modelTierSchema.optional(),
providerType: providerTypeSchema.optional(),
modelId: z.string().min(1).max(200).optional(),
});
type CompleteTaskInput = z.infer<typeof completeTaskInputSchema>;
@ -115,6 +125,10 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
const incomingMeta: Record<string, unknown> = {};
if (input.client) incomingMeta.client = input.client;
if (input.model) incomingMeta.model = input.model;
if (input.modelAlias) incomingMeta.modelAlias = input.modelAlias;
if (input.modelTier) incomingMeta.modelTier = input.modelTier;
if (input.providerType) incomingMeta.providerType = input.providerType;
if (input.modelId) incomingMeta.modelId = input.modelId;
const hasIncomingMeta = Object.keys(incomingMeta).length > 0;
const mergedMeta = hasIncomingMeta
? { ...(run.metadata ?? {}), ...incomingMeta }
@ -131,6 +145,14 @@ export function registerCompleteTaskTool(mcp: McpServer): void {
tokensTotal,
notes: input.notes ?? null,
error: input.error ?? null,
...(input.modelAlias !== undefined
? { modelAlias: input.modelAlias }
: {}),
...(input.modelTier !== undefined ? { modelTier: input.modelTier } : {}),
...(input.providerType !== undefined
? { providerType: input.providerType }
: {}),
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
...(hasIncomingMeta ? { metadata: mergedMeta } : {}),
})
.where(eq(agentRuns.id, input.runId));

View file

@ -0,0 +1,419 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Bot, Loader2, Plus, Sparkles, X } from "lucide-react";
import { useParams } from "next/navigation";
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { ProviderType } from "@tasks/shared/model-routing";
const PROVIDER_TYPES: ProviderType[] = [
"anthropic",
"openai",
"google",
"openai_compatible",
"azure_openai",
];
type ProviderForm = {
alias: string;
displayName: string;
providerType: ProviderType;
modelId: string;
baseUrl: string;
apiKeyEnvVar: string;
enabled: boolean;
};
const EMPTY_FORM: ProviderForm = {
alias: "",
displayName: "",
providerType: "openai_compatible",
modelId: "",
baseUrl: "",
apiKeyEnvVar: "",
enabled: true,
};
const PRESETS: Array<{ label: string; form: Partial<ProviderForm> }> = [
{
label: "Ollama local",
form: {
alias: "qwen-coder",
displayName: "Qwen Coder (Ollama)",
providerType: "openai_compatible",
modelId: "qwen2.5-coder:32b",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyEnvVar: "OLLAMA_API_KEY",
},
},
{
label: "MLX local",
form: {
alias: "qwen-coder-mlx",
displayName: "Qwen Coder (MLX)",
providerType: "openai_compatible",
modelId: "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit",
baseUrl: "http://127.0.0.1:8000/v1",
apiKeyEnvVar: "MLX_API_KEY",
},
},
{
label: "Anthropic",
form: {
alias: "claude-planning",
displayName: "Claude Planning",
providerType: "anthropic",
modelId: "claude-sonnet-4-20250514",
apiKeyEnvVar: "ANTHROPIC_API_KEY",
},
},
{
label: "Gemini",
form: {
alias: "gemini-flash",
displayName: "Gemini Flash",
providerType: "google",
modelId: "gemini-2.0-flash",
apiKeyEnvVar: "GOOGLE_GENERATIVE_AI_API_KEY",
},
},
{
label: "NVIDIA NIM",
form: {
alias: "nim-llama-70b",
displayName: "NIM Llama 70B",
providerType: "openai_compatible",
modelId: "meta/llama-3.1-70b-instruct",
baseUrl: "https://integrate.api.nvidia.com/v1",
apiKeyEnvVar: "NVIDIA_API_KEY",
},
},
];
export default function AiProvidersSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const listQuery = api.ai.models.listProviders.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editingId, setEditingId] = React.useState<string | null>(null);
const [form, setForm] = React.useState<ProviderForm>(EMPTY_FORM);
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
const createMut = api.ai.models.createProvider.useMutation({
onSuccess: () => {
void utils.ai.models.listProviders.invalidate();
setDialogOpen(false);
},
});
const updateMut = api.ai.models.updateProvider.useMutation({
onSuccess: () => {
void utils.ai.models.listProviders.invalidate();
setDialogOpen(false);
},
});
const deleteMut = api.ai.models.deleteProvider.useMutation({
onSuccess: () => void utils.ai.models.listProviders.invalidate(),
});
const testMut = api.ai.models.testProvider.useMutation({
onSuccess: (result, vars) => {
setTestResult((prev) => ({
...prev,
[vars.providerId]: result.ok
? `OK in ${result.latencyMs}ms`
: `Failed: ${result.error ?? "unknown"}`,
}));
},
});
const canManage = listQuery.data?.canManage ?? false;
const providers = listQuery.data?.providers ?? [];
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const openEdit = (p: (typeof providers)[number]) => {
setEditingId(p.id);
setForm({
alias: p.alias,
displayName: p.displayName,
providerType: p.providerType,
modelId: p.modelId,
baseUrl: p.baseUrl ?? "",
apiKeyEnvVar: p.apiKeyEnvVar ?? "",
enabled: p.enabled,
});
setDialogOpen(true);
};
const submit = () => {
if (!workspaceSlug) return;
const payload = {
workspace: workspaceSlug,
alias: form.alias,
displayName: form.displayName,
providerType: form.providerType,
modelId: form.modelId,
baseUrl: form.baseUrl.trim() || null,
apiKeyEnvVar: form.apiKeyEnvVar.trim() || null,
enabled: form.enabled,
};
if (editingId) {
updateMut.mutate({ ...payload, id: editingId });
} else {
createMut.mutate(payload);
}
};
if (!workspaceSlug) {
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
}
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">AI model providers</h1>
<p className="text-xs text-muted-foreground">
Registry aliases for orchestrator and pipeline roles. API keys live in server env
vars only.
</p>
</div>
</div>
{canManage ? (
<Button size="sm" className="gap-1.5" onClick={openCreate}>
<Plus className="size-4" />
Add provider
</Button>
) : null}
</div>
<SettingsAiNav workspaceSlug={workspaceSlug} />
{listQuery.isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : listQuery.error ? (
<p className="text-sm text-destructive">{listQuery.error.message}</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">
<Bot className="mx-auto mb-3 size-8 opacity-40" />
No providers yet.{" "}
{canManage ? "Add Ollama, MLX, or cloud aliases to get started." : null}
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Alias</th>
<th className="px-4 py-2 text-left font-medium">Type</th>
<th className="px-4 py-2 text-left font-medium">Model</th>
<th className="px-4 py-2 text-left font-medium">Base URL</th>
<th className="px-4 py-2 text-left font-medium">On</th>
{canManage ? (
<th className="px-4 py-2 text-right font-medium">Actions</th>
) : null}
</tr>
</thead>
<tbody className="divide-y">
{providers.map((p) => (
<tr key={p.id} className="hover:bg-muted/20">
<td className="px-4 py-2">
<div className="font-medium">{p.alias}</div>
<div className="text-xs text-muted-foreground">{p.displayName}</div>
</td>
<td className="px-4 py-2 font-mono text-xs">{p.providerType}</td>
<td className="max-w-[20ch] truncate px-4 py-2 font-mono text-xs">
{p.modelId}
</td>
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">
{p.baseUrl ?? "—"}
</td>
<td className="px-4 py-2 text-xs">{p.enabled ? "yes" : "no"}</td>
{canManage ? (
<td className="space-x-2 px-4 py-2 text-right">
<Button
variant="outline"
size="sm"
disabled={testMut.isPending}
onClick={() =>
testMut.mutate({ workspace: workspaceSlug, providerId: p.id })
}
>
Test
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete provider "${p.alias}"?`)) {
deleteMut.mutate({ workspace: workspaceSlug, id: p.id });
}
}}
>
Delete
</Button>
{testResult[p.id] ? (
<div className="mt-1 text-[10px] text-muted-foreground">
{testResult[p.id]}
</div>
) : null}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
)}
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<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 bg-background p-6 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<DialogPrimitive.Title className="text-lg font-semibold">
{editingId ? "Edit provider" : "Add provider"}
</DialogPrimitive.Title>
<DialogPrimitive.Close asChild>
<button type="button" className="rounded-md p-1 hover:bg-muted">
<X className="size-4" />
</button>
</DialogPrimitive.Close>
</div>
<div className="mb-4 flex flex-wrap gap-1.5">
{PRESETS.map((preset) => (
<Button
key={preset.label}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => setForm({ ...EMPTY_FORM, ...preset.form })}
>
{preset.label}
</Button>
))}
</div>
<div className="space-y-3">
<Field label="Alias">
<Input
value={form.alias}
onChange={(e) => setForm((f) => ({ ...f, alias: e.target.value }))}
/>
</Field>
<Field label="Display name">
<Input
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
/>
</Field>
<Field label="Provider type">
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={form.providerType}
onChange={(e) =>
setForm((f) => ({
...f,
providerType: e.target.value as ProviderType,
}))
}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Field>
<Field label="Model ID">
<Input
value={form.modelId}
onChange={(e) => setForm((f) => ({ ...f, modelId: e.target.value }))}
/>
</Field>
{form.providerType === "openai_compatible" ||
form.providerType === "azure_openai" ? (
<Field label="Base URL">
<Input
value={form.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
/>
</Field>
) : null}
<Field label="API key env var">
<Input
value={form.apiKeyEnvVar}
placeholder="ANTHROPIC_API_KEY"
onChange={(e) => setForm((f) => ({ ...f, apiKeyEnvVar: e.target.value }))}
/>
<p className="mt-1 text-[10px] text-muted-foreground">
Secret value is read from the server environment at inference time — never stored
in the database.
</p>
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
Enabled
</label>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={submit}
disabled={createMut.isPending || updateMut.isPending}
>
{createMut.isPending || updateMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
);
}

View file

@ -0,0 +1,260 @@
"use client";
import * as React from "react";
import { GitBranch, Loader2, Plus, Route } from "lucide-react";
import { useParams } from "next/navigation";
import { SettingsAiNav } from "@/components/settings/settings-ai-nav";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
const ROLE_LABELS: Record<string, string> = {
"role-conductor": "Conductor",
"role-architect": "Architect",
"role-ia-architect": "IA architect",
"role-ux-reviewer": "UX reviewer",
"role-implementer": "Implementer",
"role-reviewer": "Reviewer",
"role-design-system-auditor": "Design system auditor",
"role-a11y-auditor": "A11y auditor",
"role-doc-writer": "Doc writer",
"gate-copilot": "Gate co-pilot",
"chat-default": "Chat default",
};
function roleLabel(roleId: string): string {
return ROLE_LABELS[roleId] ?? roleId;
}
function readTier(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null;
const tier = (metadata as { tier?: unknown }).tier;
return typeof tier === "string" ? tier : null;
}
export default function AiRoleRoutingSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const routesQuery = api.ai.models.listRoleRoutes.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const providersQuery = api.ai.models.listProviders.useQuery(
{ workspace: workspaceSlug ?? "" },
{ enabled: Boolean(workspaceSlug) },
);
const [newRoleId, setNewRoleId] = React.useState("");
const [newProviderId, setNewProviderId] = React.useState("");
const upsertMut = api.ai.models.upsertRoleRoute.useMutation({
onSuccess: () => {
void utils.ai.models.listRoleRoutes.invalidate();
setNewRoleId("");
setNewProviderId("");
},
});
const deleteMut = api.ai.models.deleteRoleRoute.useMutation({
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
});
const resetMut = api.ai.models.resetPipelineDefaults.useMutation({
onSuccess: () => void utils.ai.models.listRoleRoutes.invalidate(),
});
const canManage = routesQuery.data?.canManage ?? false;
const routes = routesQuery.data?.routes ?? [];
const enabledProviders =
providersQuery.data?.providers.filter((p) => p.enabled) ?? [];
if (!workspaceSlug) {
return <p className="p-8 text-sm text-muted-foreground">No workspace selected.</p>;
}
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Route className="size-5 text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold">Role model routing</h1>
<p className="text-xs text-muted-foreground">
Map pipeline <code className="text-[10px]">role_id</code> strings to provider aliases.
Convoy <code className="text-[10px]">model_policy</code> overrides these at dispatch
time — see docs/adr-model-routing.md.
</p>
</div>
</div>
{canManage ? (
<Button
variant="outline"
size="sm"
disabled={resetMut.isPending}
onClick={() => {
if (
window.confirm(
"Insert pipeline default routes for roles whose aliases already exist?",
)
) {
resetMut.mutate({ workspace: workspaceSlug });
}
}}
>
Reset pipeline defaults
</Button>
) : null}
</div>
<SettingsAiNav workspaceSlug={workspaceSlug} />
{routesQuery.isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Role</th>
<th className="px-4 py-2 text-left font-medium">Provider alias</th>
<th className="px-4 py-2 text-left font-medium">Tier</th>
<th className="px-4 py-2 text-left font-medium">On</th>
{canManage ? (
<th className="px-4 py-2 text-right font-medium">Actions</th>
) : null}
</tr>
</thead>
<tbody className="divide-y">
{routes.length === 0 ? (
<tr>
<td
colSpan={canManage ? 5 : 4}
className="px-4 py-8 text-center text-muted-foreground"
>
No routes configured. Add providers first, then map roles.
</td>
</tr>
) : (
routes.map((route) => {
const tier = readTier(route.providerMetadata);
return (
<tr key={route.id} className="hover:bg-muted/20">
<td className="px-4 py-2">
<div className="font-medium">{roleLabel(route.roleId)}</div>
<div className="font-mono text-[10px] text-muted-foreground">
{route.roleId}
</div>
</td>
<td className="px-4 py-2 font-mono text-xs">{route.providerAlias}</td>
<td className="px-4 py-2">
{tier ? (
<Badge variant="secondary" className="text-[10px]">
{tier}
</Badge>
) : (
"—"
)}
</td>
<td className="px-4 py-2 text-xs">
{route.enabled && route.providerEnabled ? "yes" : "no"}
</td>
{canManage ? (
<td className="px-4 py-2 text-right">
<select
className="mr-2 rounded border border-input bg-background px-2 py-1 text-xs"
value={route.providerAliasId}
onChange={(e) =>
upsertMut.mutate({
workspace: workspaceSlug,
roleId: route.roleId,
providerAliasId: e.target.value,
})
}
>
{enabledProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.alias}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => {
if (window.confirm(`Remove route for ${route.roleId}?`)) {
deleteMut.mutate({ workspace: workspaceSlug, id: route.id });
}
}}
>
Remove
</Button>
</td>
) : null}
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
{canManage ? (
<div className="mt-8 rounded-lg border bg-muted/20 p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<GitBranch className="size-4" />
Add custom role
</div>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">role_id</label>
<Input
className="w-48 font-mono text-xs"
placeholder="gate-copilot"
value={newRoleId}
onChange={(e) => setNewRoleId(e.target.value)}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Provider</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
>
<option value="">Select…</option>
{enabledProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.alias}
</option>
))}
</select>
</div>
<Button
size="sm"
className="gap-1"
disabled={!newRoleId.trim() || !newProviderId || upsertMut.isPending}
onClick={() =>
upsertMut.mutate({
workspace: workspaceSlug,
roleId: newRoleId.trim(),
providerAliasId: newProviderId,
})
}
>
<Plus className="size-3.5" />
Add
</Button>
</div>
</div>
) : null}
</div>
);
}

View file

@ -66,7 +66,7 @@ function formatTimestamp(when: Date | string): string {
* for in-flight runs (no `finishedAt`). Below a minute we show seconds
* with a single decimal so quick MCP calls don't all look like "0s".
*/
export function formatDuration(
function formatDuration(
startedAt: Date | string,
finishedAt: Date | string | null,
): string {
@ -105,6 +105,19 @@ function describeOutcome(
* null and ignore values that aren't strings (defensive against
* hand-inserted rows from psql).
*/
function describeModel(row: {
modelAlias: string | null;
modelTier: string | null;
modelId: string | null;
metadata: unknown;
}): string | null {
if (row.modelAlias) {
const tier = row.modelTier ? ` (${row.modelTier})` : "";
return `${row.modelAlias}${tier}`;
}
return describeAgent(row.metadata).label;
}
function describeAgent(metadata: unknown): {
client: string | null;
model: string | null;
@ -221,6 +234,7 @@ export default function RunsPage() {
<th className="px-4 py-2 text-left font-medium">Started</th>
<th className="px-4 py-2 text-left font-medium">Duration</th>
<th className="px-4 py-2 text-left font-medium">Actor</th>
<th className="px-4 py-2 text-left font-medium">Model</th>
<th className="px-4 py-2 text-left font-medium">Outcome</th>
<th className="px-4 py-2 text-right font-medium">Tokens</th>
</tr>
@ -229,6 +243,7 @@ export default function RunsPage() {
{runsQuery.data.rows.map((row) => {
const { label, key } = describeOutcome(row.outcome, row.finishedAt);
const agent = describeAgent(row.metadata);
const modelLabel = describeModel(row);
const isOpen = expanded === row.id;
const hasDetail = Boolean(row.notes || row.error);
return (
@ -289,6 +304,9 @@ export default function RunsPage() {
) : null}
</div>
</td>
<td className="max-w-[16ch] truncate px-4 py-2 text-xs text-muted-foreground">
{modelLabel ?? "—"}
</td>
<td className="px-4 py-2">
<span
className={cn(
@ -306,7 +324,7 @@ export default function RunsPage() {
{isOpen && hasDetail ? (
<tr>
<td />
<td colSpan={6} className="bg-muted/20 px-4 py-3 text-xs">
<td colSpan={7} className="bg-muted/20 px-4 py-3 text-xs">
{row.notes ? (
<div className="mb-2">
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">

View file

@ -19,6 +19,7 @@ import {
ScrollText,
Search,
Settings,
Sparkles,
User,
Users,
Zap,
@ -144,6 +145,24 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
<ScrollText className="size-4 text-muted-foreground" />
Audit log
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/ai/providers`)
}
>
<Sparkles className="size-4 text-muted-foreground" />
AI providers
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/ai/role-routing`)
}
>
<Sparkles className="size-4 text-muted-foreground" />
Role routing
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>

View file

@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const LINKS = [
{ segment: "providers", label: "Providers" },
{ segment: "role-routing", label: "Role routing" },
] as const;
export function SettingsAiNav({ workspaceSlug }: { workspaceSlug: string }) {
const pathname = usePathname();
const base = `/${workspaceSlug}/settings/ai`;
return (
<nav className="mb-8 flex gap-1 border-b border-border">
{LINKS.map((link) => {
const href = `${base}/${link.segment}`;
const active = pathname === href || pathname?.startsWith(`${href}/`);
return (
<Link
key={link.segment}
href={href}
className={cn(
"-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors",
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{link.label}
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,533 @@
import { createLanguageModelFromResolved } from "@tasks/ai";
import { seedDefaultRoleRoutes } from "@tasks/database/model-routing";
import {
modelProviders,
roleModelRoutes,
} from "@tasks/database/schema";
import {
convoyModelPolicySchema,
providerTypeSchema,
resolveModel,
type ModelProvider,
type RoleModelRoute,
} from "@tasks/shared/model-routing";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { rateLimit } from "@/server/lib/rate-limit";
import { router, workspaceProcedure } from "@/server/trpc";
import type { WorkspaceProcedureContext } from "@/server/trpc";
type ProviderRow = typeof modelProviders.$inferSelect;
type RouteRow = typeof roleModelRoutes.$inferSelect;
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 model providers.",
});
}
}
function canManageWorkspace(ctx: WorkspaceProcedureContext): boolean {
return ctx.workspace.role === "owner" || ctx.workspace.role === "admin";
}
function mapProviderRow(row: ProviderRow): ModelProvider {
return {
id: row.id,
workspaceId: row.workspaceId,
alias: row.alias,
displayName: row.displayName,
providerType: providerTypeSchema.parse(row.providerType),
modelId: row.modelId,
baseUrl: row.baseUrl,
apiKeyEnvVar: row.apiKeyEnvVar,
extraHeaders: row.extraHeaders,
defaultParams: row.defaultParams,
metadata: row.metadata,
enabled: row.enabled,
};
}
function mapRouteRow(row: RouteRow): RoleModelRoute {
return {
id: row.id,
workspaceId: row.workspaceId,
roleId: row.roleId,
providerAliasId: row.providerAliasId,
enabled: row.enabled,
sortOrder: row.sortOrder,
};
}
function readApiKey(envVarName: string): string | undefined {
return process.env[envVarName]?.trim() || undefined;
}
function validateProviderFields(input: {
providerType: z.infer<typeof providerTypeSchema>;
baseUrl?: string | null;
}): void {
if (input.providerType === "openai_compatible" && !input.baseUrl?.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "baseUrl is required for openai_compatible providers.",
});
}
}
function providerToResolved(row: ProviderRow): ReturnType<typeof resolveModel> {
const provider = mapProviderRow(row);
const result = resolveModel({
roleId: "test",
providers: [provider],
routes: [],
briefRecommendedModel: provider.alias,
getApiKey: readApiKey,
});
return result;
}
const providerFieldsSchema = z.object({
alias: z.string().min(1).max(120),
displayName: z.string().min(1).max(200),
providerType: providerTypeSchema,
modelId: z.string().min(1).max(200),
baseUrl: z.string().url().nullable().optional(),
apiKeyEnvVar: z.string().min(1).max(120).nullable().optional(),
extraHeaders: z.record(z.string()).nullable().optional(),
defaultParams: z
.object({
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
})
.nullable()
.optional(),
metadata: z.record(z.unknown()).nullable().optional(),
enabled: z.boolean().optional(),
});
export const aiModelsRouter = router({
listProviders: workspaceProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select()
.from(modelProviders)
.where(eq(modelProviders.workspaceId, ctx.workspace.id))
.orderBy(asc(modelProviders.alias));
return {
providers: rows.map(mapProviderRow),
canManage: canManageWorkspace(ctx),
};
}),
createProvider: workspaceProcedure
.input(providerFieldsSchema)
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
validateProviderFields(input);
try {
const [created] = await ctx.db
.insert(modelProviders)
.values({
workspaceId: ctx.workspace.id,
alias: input.alias.trim(),
displayName: input.displayName.trim(),
providerType: input.providerType,
modelId: input.modelId.trim(),
baseUrl: input.baseUrl?.trim() || null,
apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null,
extraHeaders: input.extraHeaders ?? null,
defaultParams: input.defaultParams ?? null,
metadata: input.metadata ?? null,
enabled: input.enabled ?? true,
updatedAt: new Date(),
})
.returning();
if (!created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create provider.",
});
}
return mapProviderRow(created);
} catch (e) {
if (isUniqueViolation(e)) {
throw new TRPCError({
code: "CONFLICT",
message: `Alias "${input.alias}" already exists in this workspace.`,
});
}
throw e;
}
}),
updateProvider: workspaceProcedure
.input(
providerFieldsSchema.partial().extend({
id: z.string().uuid(),
}),
)
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
const [existing] = await ctx.db
.select()
.from(modelProviders)
.where(
and(
eq(modelProviders.id, input.id),
eq(modelProviders.workspaceId, ctx.workspace.id),
),
)
.limit(1);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
}
const nextType = input.providerType ?? existing.providerType;
const nextBaseUrl =
input.baseUrl !== undefined ? input.baseUrl : existing.baseUrl;
validateProviderFields({
providerType: providerTypeSchema.parse(nextType),
baseUrl: nextBaseUrl,
});
try {
const [updated] = await ctx.db
.update(modelProviders)
.set({
...(input.alias !== undefined ? { alias: input.alias.trim() } : {}),
...(input.displayName !== undefined
? { displayName: input.displayName.trim() }
: {}),
...(input.providerType !== undefined
? { providerType: input.providerType }
: {}),
...(input.modelId !== undefined ? { modelId: input.modelId.trim() } : {}),
...(input.baseUrl !== undefined
? { baseUrl: input.baseUrl?.trim() || null }
: {}),
...(input.apiKeyEnvVar !== undefined
? { apiKeyEnvVar: input.apiKeyEnvVar?.trim() || null }
: {}),
...(input.extraHeaders !== undefined
? { extraHeaders: input.extraHeaders }
: {}),
...(input.defaultParams !== undefined
? { defaultParams: input.defaultParams }
: {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(modelProviders.id, input.id),
eq(modelProviders.workspaceId, ctx.workspace.id),
),
)
.returning();
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
}
return mapProviderRow(updated);
} catch (e) {
if (isUniqueViolation(e)) {
throw new TRPCError({
code: "CONFLICT",
message: "Another provider already uses that alias.",
});
}
throw e;
}
}),
deleteProvider: workspaceProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
const [deleted] = await ctx.db
.delete(modelProviders)
.where(
and(
eq(modelProviders.id, input.id),
eq(modelProviders.workspaceId, ctx.workspace.id),
),
)
.returning({ id: modelProviders.id });
if (!deleted) {
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
}
return { id: deleted.id };
}),
testProvider: workspaceProcedure
.input(z.object({ providerId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
const limit = rateLimit({
key: `ai:testProvider:${ctx.workspace.id}`,
limit: 10,
windowMs: 60_000,
});
if (!limit.allowed) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: "Test rate limit exceeded. Try again shortly.",
});
}
const [row] = await ctx.db
.select()
.from(modelProviders)
.where(
and(
eq(modelProviders.id, input.providerId),
eq(modelProviders.workspaceId, ctx.workspace.id),
),
)
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
}
const resolved = providerToResolved(row);
if (!resolved.ok) {
return {
ok: false as const,
latencyMs: 0,
error: resolved.error.message,
};
}
const client = createLanguageModelFromResolved(resolved.model);
if (!client.ok) {
return { ok: false as const, latencyMs: 0, error: client.reason };
}
const started = Date.now();
try {
await generateText({
model: client.model,
prompt: "Reply with exactly: OK",
maxTokens: 5,
temperature: 0,
});
return {
ok: true as const,
latencyMs: Date.now() - started,
};
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {
ok: false as const,
latencyMs: Date.now() - started,
error: message,
};
}
}),
listRoleRoutes: workspaceProcedure.query(async ({ ctx }) => {
const routes = await ctx.db
.select({
route: roleModelRoutes,
providerAlias: modelProviders.alias,
providerEnabled: modelProviders.enabled,
providerMetadata: modelProviders.metadata,
})
.from(roleModelRoutes)
.innerJoin(
modelProviders,
eq(roleModelRoutes.providerAliasId, modelProviders.id),
)
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id))
.orderBy(asc(roleModelRoutes.sortOrder), asc(roleModelRoutes.roleId));
return {
routes: routes.map((row) => ({
...mapRouteRow(row.route),
providerAlias: row.providerAlias,
providerEnabled: row.providerEnabled,
providerMetadata: row.providerMetadata,
})),
canManage: canManageWorkspace(ctx),
};
}),
upsertRoleRoute: workspaceProcedure
.input(
z.object({
roleId: z.string().min(1).max(120),
providerAliasId: z.string().uuid(),
enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
const [provider] = await ctx.db
.select({ id: modelProviders.id })
.from(modelProviders)
.where(
and(
eq(modelProviders.id, input.providerAliasId),
eq(modelProviders.workspaceId, ctx.workspace.id),
),
)
.limit(1);
if (!provider) {
throw new TRPCError({ code: "NOT_FOUND", message: "Provider not found." });
}
const [existing] = await ctx.db
.select({ id: roleModelRoutes.id })
.from(roleModelRoutes)
.where(
and(
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
eq(roleModelRoutes.roleId, input.roleId),
),
)
.limit(1);
if (existing) {
const [updated] = await ctx.db
.update(roleModelRoutes)
.set({
providerAliasId: input.providerAliasId,
enabled: input.enabled ?? true,
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
updatedAt: new Date(),
})
.where(
and(
eq(roleModelRoutes.id, existing.id),
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
),
)
.returning();
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
}
return mapRouteRow(updated);
}
const [created] = await ctx.db
.insert(roleModelRoutes)
.values({
workspaceId: ctx.workspace.id,
roleId: input.roleId,
providerAliasId: input.providerAliasId,
enabled: input.enabled ?? true,
sortOrder: input.sortOrder ?? 0,
updatedAt: new Date(),
})
.returning();
if (!created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create role route.",
});
}
return mapRouteRow(created);
}),
deleteRoleRoute: workspaceProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
assertWorkspaceAdmin(ctx);
const [deleted] = await ctx.db
.delete(roleModelRoutes)
.where(
and(
eq(roleModelRoutes.id, input.id),
eq(roleModelRoutes.workspaceId, ctx.workspace.id),
),
)
.returning({ id: roleModelRoutes.id });
if (!deleted) {
throw new TRPCError({ code: "NOT_FOUND", message: "Route not found." });
}
return { id: deleted.id };
}),
resetPipelineDefaults: workspaceProcedure.mutation(async ({ ctx }) => {
assertWorkspaceAdmin(ctx);
const result = await seedDefaultRoleRoutes(ctx.db, ctx.workspace.id);
return result;
}),
resolveForRole: workspaceProcedure
.input(
z.object({
roleId: z.string().min(1).max(120),
briefRecommendedModel: z.string().optional(),
convoyPolicy: convoyModelPolicySchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const providerRows = await ctx.db
.select()
.from(modelProviders)
.where(eq(modelProviders.workspaceId, ctx.workspace.id));
const routeRows = await ctx.db
.select()
.from(roleModelRoutes)
.where(eq(roleModelRoutes.workspaceId, ctx.workspace.id));
const result = resolveModel({
roleId: input.roleId,
providers: providerRows.map(mapProviderRow),
routes: routeRows.map(mapRouteRow),
briefRecommendedModel: input.briefRecommendedModel,
convoyPolicy: input.convoyPolicy,
getApiKey: readApiKey,
});
if (!result.ok) {
return result;
}
const { apiKey: _removed, ...publicModel } = result.model;
void _removed;
return {
ok: true as const,
model: publicModel,
source: result.source,
resolvedAlias: result.resolvedAlias,
};
}),
});
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: string }).code === "23505"
);
}

View file

@ -6,6 +6,7 @@ import { z } from "zod";
import { db as dbInstance } from "@tasks/database";
import { objects } from "@tasks/database/schema";
import { router, workspaceProcedure } from "@/server/trpc";
import { aiModelsRouter } from "@/server/routers/ai-models";
type Db = typeof dbInstance;
@ -185,4 +186,6 @@ export const aiRouter = router({
}
return { actions: suggestionsForContext({ ...input, objectType }) };
}),
models: aiModelsRouter,
});

View file

@ -73,6 +73,10 @@ export const runsRouter = router({
tokensTotal: agentRuns.tokensTotal,
notes: agentRuns.notes,
metadata: agentRuns.metadata,
modelAlias: agentRuns.modelAlias,
providerType: agentRuns.providerType,
modelId: agentRuns.modelId,
modelTier: agentRuns.modelTier,
})
.from(agentRuns)
.leftJoin(
@ -147,6 +151,10 @@ export const runsRouter = router({
tokensTotal: agentRuns.tokensTotal,
notes: agentRuns.notes,
metadata: agentRuns.metadata,
modelAlias: agentRuns.modelAlias,
providerType: agentRuns.providerType,
modelId: agentRuns.modelId,
modelTier: agentRuns.modelTier,
})
.from(agentRuns)
.leftJoin(

362
docs/adr-model-routing.md Normal file
View file

@ -0,0 +1,362 @@
# ADR: Model routing and provider registry
**Status:** Accepted (draft implementation)
**Date:** 2026-07-03
**Context:** Echodo automation foundation — orchestrator, worker, and mission-control UI without Cursor as the runtime substrate.
## Decision
Echodo will own a **workspace-scoped model provider registry** and an **extensible role → model routing table**. Every automated pipeline run resolves a concrete provider configuration before inference. Resolution is deterministic, logged on every run, and configurable from Settings without redeploying orchestrator or worker code.
## Problem
The agent-pipeline defines per-role models via:
- `model:` frontmatter in `.cursor/agents/role-*.md` (Cursor-only enforcement)
- `model_policy.roles` in convoy frontmatter (recommendation only)
- `recommended_model` on implementer briefs (optional override)
That works for manual Cursor sessions. It does **not** work when:
- An orchestrator dispatches `role-implementer` unattended
- Planning uses Claude API and implementation uses local Ollama on a Mac
- Operators add providers (OpenAI, Gemini, NVIDIA NIM, LiteLLM gateway) without code changes
- Custom roles (e.g. `gate-copilot`, `role-security-auditor`) need routing
We need one contract that web chat, orchestrator, worker, and MCP logging all share.
## Goals
1. **Per-role model selection** — conductor/architect on cloud; implementer/auditors on local coder models.
2. **Extensible providers** — Anthropic, OpenAI, Google (Gemini), OpenAI-compatible (Ollama, LiteLLM, NVIDIA NIM, Azure OpenAI).
3. **Extensible roles** — seed pipeline’s nine L2 roles; allow arbitrary `roleId` rows.
4. **Layered overrides** — workspace defaults → convoy `model_policy` → brief `recommended_model`.
5. **No secrets in DB** — store `apiKeyEnvVar` names; values live in deploy env (Coolify/Dokploy/Mac).
6. **Observable** — every `agent_runs` / `convoy_events` row records alias, provider type, model id, tier.
## Non-goals (this ADR)
- Implementing the orchestrator or worker (separate epics).
- LiteLLM as a required dependency (optional alias pointing at a gateway URL).
- Storing API keys in Postgres or committed YAML.
- Replacing `docs/model-routing-policy.md` tier guidance — this ADR **implements** it in Echodo.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Settings → AI → Providers (ModelProviderAlias CRUD) │
│ Settings → AI → Role routing (RoleModelRoute CRUD) │
└────────────────────────────┬────────────────────────────────┘
│
resolveModel(workspaceId, roleId, ctx?)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
apps/web apps/orchestrator apps/worker
(chat, gate (dispatch job (inference
co-pilot) with ModelRef) only)
```
### Resolution order (first match wins)
1. **Brief override** — `recommended_model` alias on `.convoys/<slug>/brief-N-*.md`
2. **Convoy override** — `model_policy.roles[roleId]` in `.convoys/<slug>.md`
3. **Workspace route** — `role_model_routes` row for `(workspace_id, role_id)`
4. **Pipeline template** — shipped defaults (nine L2 roles + `gate-copilot`, `chat-default`)
If the alias is missing or disabled, fail closed with a structured error — do not silently fall back to a hardcoded model.
### Escalation
When `model_policy.escalate_to` is set (convoy) or workspace policy defines escalation:
- After N failed implementer attempts (default: 2), orchestrator re-dispatches the same brief with the escalation alias.
- Log `model_tier` change on the new run.
## Data model
### `model_providers` (per workspace)
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `workspace_id` | uuid FK | Required; all queries filter by this |
| `alias` | varchar | Unique per workspace, e.g. `claude-planning` |
| `display_name` | varchar | UI label |
| `provider_type` | enum | See below |
| `model_id` | varchar | Provider-native model string |
| `base_url` | text nullable | Required for `openai_compatible` |
| `api_key_env_var` | varchar nullable | e.g. `ANTHROPIC_API_KEY` |
| `extra_headers` | jsonb nullable | Optional HTTP headers |
| `default_params` | jsonb nullable | `temperature`, `maxTokens`, … |
| `metadata` | jsonb nullable | `tier`, cost hints for dashboard |
| `enabled` | boolean | Default true |
| `created_at` / `updated_at` | timestamptz | |
**`provider_type` enum:**
| Value | Client | Examples |
|-------|--------|----------|
| `anthropic` | `@ai-sdk/anthropic` | Claude API |
| `openai` | `@ai-sdk/openai` | OpenAI API |
| `google` | `@ai-sdk/google` | Gemini |
| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | Ollama, LiteLLM, **NVIDIA NIM**, vLLM |
| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure deployments |
Future: `custom` with a registered adapter — not in v1.
### `role_model_routes` (per workspace)
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `workspace_id` | uuid FK | |
| `role_id` | varchar | e.g. `role-implementer`, `gate-copilot` |
| `provider_alias_id` | uuid FK → `model_providers.id` | |
| `enabled` | boolean | |
| `sort_order` | int | UI ordering |
| `created_at` / `updated_at` | timestamptz | |
Unique constraint: `(workspace_id, role_id)`.
`role_id` is a **string**, not a DB enum — new pipeline roles do not require migrations.
### Resolved config (runtime, not persisted)
```typescript
type ResolvedModel = {
alias: string;
providerType: ProviderType;
modelId: string;
baseUrl?: string;
apiKey: string; // read from process.env[apiKeyEnvVar] at call time
extraHeaders?: Record<string, string>;
defaultParams?: { temperature?: number; maxTokens?: number };
tier?: "premium" | "fast" | "local" | "auto";
};
```
Pure function: `resolveModel(input, db)` lives in `packages/shared` (types + zod) with implementation in `packages/ai` or `packages/shared` (no React, no DB imports in shared — DB fetch in tRPC/orchestrator, pass rows into resolver).
## Provider examples
### Local Ollama (Mac)
```yaml
alias: qwen-coder
provider_type: openai_compatible
model_id: qwen2.5-coder:32b
base_url: http://192.168.1.42:11434/v1
api_key_env_var: OLLAMA_API_KEY # any non-empty string Ollama accepts
metadata: { tier: local }
```
### Local MLX on Apple Silicon (Mac)
[MLX](https://github.com/ml-explore/mlx) is Apple's on-device ML framework. Echodo does **not** talk to MLX directly — use an **OpenAI-compatible HTTP server** in front of MLX, same `provider_type: openai_compatible` as Ollama.
| Server | Package | Default port | When to use |
|--------|---------|--------------|-------------|
| **mlx_lm.server** | `pip install mlx-lm` | 8080 | Solo dev, single implementer, simplest path |
| **mlx-openai-server** | [cubist38/mlx-openai-server](https://github.com/cubist38/mlx-openai-server) | 8000 | **Parallel implementer fan-out** — continuous batching handles concurrent requests better than the reference server |
| mlx-lm-server (community) | [0xDaizz/mlx-lm-server](https://github.com/0xDaizz/mlx-lm-server) | 8000 | Alternative if mlx-openai-server doesn't fit |
**Parallel implementers note:** When the orchestrator dispatches multiple `role-implementer` jobs at once (disjoint briefs per agent-pipeline `slice_dependencies`), a single local inference process can become the bottleneck. Prefer **mlx-openai-server** (or mlx-lm-server with batching) over the minimal `mlx_lm.server` when running 2+ implementers against the same Mac. Ollama can also serve parallel requests but MLX-native models (`mlx-community/*` on Hugging Face) often perform better on Apple Silicon unified memory. You may run **both** Ollama (`:11434`) and an MLX server (`:8000` / `:8080`) as separate registry aliases and route roles accordingly.
```yaml
alias: qwen-coder-mlx
provider_type: openai_compatible
model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit
base_url: http://192.168.1.42:8000/v1 # mlx-openai-server; use :8080/v1 for mlx_lm.server
api_key_env_var: MLX_API_KEY # placeholder; local servers ignore it
metadata: { tier: local, runtime: mlx }
```
**Operator setup (mlx-openai-server example):**
```bash
# Machine-wide install (all repos): ~/mlx-inference/mlx-serve.sh
# See ~/mlx-inference/README.md
~/mlx-inference/mlx-serve.sh
# Or manual:
pip install mlx-lm mlx-openai-server
mlx-openai-server launch \
--model-path mlx-community/Qwen2.5-Coder-32B-Instruct-4bit \
--model-type lm \
--host 0.0.0.0 \
--port 8000
```
**Networking:** Bind `0.0.0.0` when Echodo (Coolify) or `apps/worker` on another host must reach the Mac over LAN. Restrict firewall to homelab subnet. Do not expose raw local inference to the public internet.
**v1 registry:** No `provider_type: mlx` — HTTP gateway only. Native MLX SDK in `apps/worker` is out of scope.
```yaml
alias: claude-planning
provider_type: anthropic
model_id: claude-sonnet-4-20250514
api_key_env_var: ANTHROPIC_API_KEY
metadata: { tier: premium }
```
### NVIDIA NIM
```yaml
alias: nim-llama-70b
provider_type: openai_compatible
model_id: meta/llama-3.1-70b-instruct
base_url: https://integrate.api.nvidia.com/v1
api_key_env_var: NVIDIA_API_KEY
```
### Gemini
```yaml
alias: gemini-flash
provider_type: google
model_id: gemini-2.0-flash
api_key_env_var: GOOGLE_GENERATIVE_AI_API_KEY
```
### LiteLLM gateway (optional)
```yaml
alias: litellm-gateway
provider_type: openai_compatible
model_id: qwen2.5-coder:32b # or per-role model_id in route table
base_url: http://host:4000/v1
api_key_env_var: LITELLM_MASTER_KEY
```
## Default role routing (seed template)
Shipped when a workspace enables pipeline integration:
| role_id | Suggested alias | tier |
|---------|-----------------|------|
| `role-conductor` | `claude-planning` | premium |
| `role-architect` | `claude-planning` | premium |
| `role-ia-architect` | `claude-planning` | premium |
| `role-ux-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-implementer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-design-system-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-a11y-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-doc-writer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `gate-copilot` | `claude-planning` | premium |
| `chat-default` | workspace choice | — |
Operators remap aliases in Settings; seed is not immutable.
## Convoy / brief integration
Convoy frontmatter uses **registry aliases**, not Cursor model slugs:
```yaml
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
escalate_to: claude-planning
```
Brief frontmatter:
```yaml
recommended_model: qwen-coder
model_tier: fast
```
Importer/parser for convoy files is out of scope for the first epic; orchestrator reads frontmatter when dispatching.
## API surface (tRPC)
Router: `ai.models` (or `pipeline.models`)
| Procedure | Purpose |
|-----------|---------|
| `listProviders` | Paginated; workspace-scoped |
| `createProvider` / `updateProvider` / `deleteProvider` | Admin-only |
| `testProvider` | One-token completion; returns latency + error detail |
| `listRoleRoutes` | All routes for workspace |
| `upsertRoleRoute` | Set role → alias; supports custom `roleId` |
| `deleteRoleRoute` | Remove custom role |
| `resolveForRole` | Debug: given `roleId` + optional convoy/brief context |
## UI
- `/{workspaceSlug}/settings/ai/providers` — provider table + add/edit dialog + test button
- `/{workspaceSlug}/settings/ai/role-routing` — role → alias table + add custom role
- Link from convoy/gate screens: “Model for this step: `qwen-coder` (change)”
## Logging contract
Extend `agent_runs` (and future `convoy_events`) with:
- `model_alias` varchar nullable
- `provider_type` varchar nullable
- `model_id` varchar nullable
- `model_tier` varchar nullable
`complete_task` MCP tool accepts optional `modelAlias` / `modelTier` for close-out logging.
## Security
- Provider CRUD: workspace admin only (same bar as `updateWorkflowPrompt`).
- `testProvider` runs server-side; rate-limit per workspace.
- Never return resolved API keys to the browser — only alias and env var **name**.
- Worker/orchestrator processes must have env vars for aliases they use.
## Relationship to other ADRs / plans
| Artifact | Relationship |
|----------|----------------|
| `docs/model-routing-policy.md` | Tier guidance; Cursor-era slugs migrate to aliases |
| `plans/Plan-agent-coordination` | `claim_task` / `complete_task` log model fields |
| `plans/Plan-automation-foundation` | Implements this ADR (model registry epic) |
| Future orchestrator epic | Calls `resolveModel` on every dispatch |
| Future worker epic | Consumes `ResolvedModel` only |
## Migration from Cursor slugs
Legacy convoys may reference `claude-4.6-opus-high-thinking` or `composer-2.5-fast`. Optional alias map in workspace metadata:
```json
{
"legacy_model_aliases": {
"claude-4.6-opus-high-thinking": "claude-planning",
"composer-2.5-fast": "qwen-coder"
}
}
```
## Consequences
**Positive**
- Single place to change Claude vs Qwen vs NIM without code deploys.
- Orchestrator and worker stay provider-agnostic.
- Extensible roles support pipeline evolution.
**Negative**
- Operators must configure providers before automation runs (mitigated: test button + clear errors).
- Two sources of truth for convoy overrides (markdown + DB) until convoy lifecycle MCP lands — resolution order is explicit.
## Open questions
1. **Repo-level `echodo.models.yml`** — mirror workspace config in git for bootstrap? Deferred; DB is v1 source.
2. **Per-user overrides** — deferred; workspace-level only for v1.
3. **Budget caps per alias** — defer to LiteLLM or Phase 4 workers.
## References
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`
- Agent-pipeline v0.4 plan: convoy lifecycle + Echodo integration
- Vercel AI SDK provider packages in `packages/ai`
- MLX: [ml-explore/mlx](https://github.com/ml-explore/mlx), [mlx-lm SERVER.md](https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md), [mlx-openai-server](https://github.com/cubist38/mlx-openai-server)
- Free-tier discovery: [cheahjs/free-llm-api-resources](https://github.com/cheahjs/free-llm-api-resources), [mnfst/awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis)

View file

@ -17,6 +17,7 @@
"ai": "^4.0.0",
"@ai-sdk/openai": "^1.0.0",
"@ai-sdk/anthropic": "^1.0.0",
"@ai-sdk/google": "^1.0.0",
"@tasks/shared": "workspace:*",
"zod": "^3.24.0"
},

View file

@ -5,6 +5,10 @@ export {
type OpenAIClientResult,
type OpenAIModelId,
} from "./providers/openai";
export {
createLanguageModelFromResolved,
type CreateLanguageModelResult,
} from "./providers/resolve-client";
export {
EDITOR_SYSTEM_PROMPT,
GENERAL_SYSTEM_PROMPT,

View file

@ -0,0 +1,48 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import type { LanguageModel } from "ai";
import type { ResolvedModel } from "@tasks/shared/model-routing";
export type CreateLanguageModelResult =
| { ok: true; model: LanguageModel }
| { ok: false; reason: string };
/**
* Build a Vercel AI SDK `LanguageModel` from a resolved registry config.
* Callers must pass a fully resolved `ResolvedModel` (including apiKey).
*/
export function createLanguageModelFromResolved(
resolved: ResolvedModel,
): CreateLanguageModelResult {
try {
switch (resolved.providerType) {
case "anthropic": {
const anthropic = createAnthropic({ apiKey: resolved.apiKey });
return { ok: true, model: anthropic(resolved.modelId) };
}
case "openai":
case "openai_compatible":
case "azure_openai": {
const openai = createOpenAI({
apiKey: resolved.apiKey,
baseURL: resolved.baseUrl,
});
return { ok: true, model: openai(resolved.modelId) };
}
case "google": {
const google = createGoogleGenerativeAI({ apiKey: resolved.apiKey });
return { ok: true, model: google(resolved.modelId) };
}
default:
return {
ok: false,
reason: `Unsupported provider type: ${resolved.providerType as string}`,
};
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, reason: message };
}
}

View file

@ -0,0 +1,36 @@
CREATE TABLE "model_providers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"alias" varchar(120) NOT NULL,
"display_name" varchar(200) NOT NULL,
"provider_type" varchar(40) NOT NULL,
"model_id" varchar(200) NOT NULL,
"base_url" text,
"api_key_env_var" varchar(120),
"extra_headers" jsonb,
"default_params" jsonb,
"metadata" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "role_model_routes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"role_id" varchar(120) NOT NULL,
"provider_alias_id" uuid NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "model_providers" ADD CONSTRAINT "model_providers_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "role_model_routes" ADD CONSTRAINT "role_model_routes_provider_alias_id_model_providers_id_fk" FOREIGN KEY ("provider_alias_id") REFERENCES "public"."model_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "model_providers_workspace_id_idx" ON "model_providers" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "model_providers_workspace_id_alias_unique" ON "model_providers" USING btree ("workspace_id","alias");--> statement-breakpoint
CREATE INDEX "role_model_routes_workspace_id_idx" ON "role_model_routes" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "role_model_routes_workspace_id_role_id_unique" ON "role_model_routes" USING btree ("workspace_id","role_id");--> statement-breakpoint
CREATE INDEX "role_model_routes_provider_alias_id_idx" ON "role_model_routes" USING btree ("provider_alias_id");

View file

@ -0,0 +1,4 @@
ALTER TABLE "agent_runs" ADD COLUMN "model_alias" varchar(120);--> statement-breakpoint
ALTER TABLE "agent_runs" ADD COLUMN "provider_type" varchar(40);--> statement-breakpoint
ALTER TABLE "agent_runs" ADD COLUMN "model_id" varchar(200);--> statement-breakpoint
ALTER TABLE "agent_runs" ADD COLUMN "model_tier" varchar(20);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,20 @@
"when": 1780455865684,
"tag": "0009_loving_rogue",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1783450794221,
"tag": "0010_legal_stryfe",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1783451144455,
"tag": "0011_cool_captain_america",
"breakpoints": true
}
]
}

View file

@ -9,7 +9,8 @@
".": "./src/index.ts",
"./schema": "./src/schema/index.ts",
"./client": "./src/client.ts",
"./markdown-backlog": "./src/markdown-backlog/index.ts"
"./markdown-backlog": "./src/markdown-backlog/index.ts",
"./model-routing": "./src/model-routing/index.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate",
@ -23,6 +24,7 @@
"import:markdown-backlog": "tsx src/scripts/import-markdown-backlog.ts"
},
"dependencies": {
"@tasks/shared": "workspace:*",
"drizzle-orm": "^0.38.0",
"postgres": "^3.4.5",
"yaml": "^2.8.3",

View file

@ -0,0 +1,2 @@
export { seedDefaultRoleRoutes } from "./seed-default-role-routes";
export type { SeedDefaultRoleRoutesResult } from "./seed-default-role-routes";

View file

@ -0,0 +1,67 @@
import { and, eq } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { PIPELINE_DEFAULT_ROLE_ROUTES } from "@tasks/shared/model-routing";
import type * as schema from "../schema/index";
import { modelProviders, roleModelRoutes } from "../schema/index";
type Database = PostgresJsDatabase<typeof schema>;
export type SeedDefaultRoleRoutesResult = {
inserted: number;
skipped: number;
};
/**
* Inserts pipeline default role → alias routes for a workspace when matching
* provider aliases already exist. Does not create providers.
*/
export async function seedDefaultRoleRoutes(
db: Database,
workspaceId: string,
): Promise<SeedDefaultRoleRoutesResult> {
const providers = await db
.select({ id: modelProviders.id, alias: modelProviders.alias })
.from(modelProviders)
.where(eq(modelProviders.workspaceId, workspaceId));
const aliasToId = new Map(providers.map((row) => [row.alias, row.id]));
let inserted = 0;
let skipped = 0;
let sortOrder = 0;
for (const [roleId, alias] of Object.entries(PIPELINE_DEFAULT_ROLE_ROUTES)) {
const providerId = aliasToId.get(alias);
if (!providerId) {
skipped++;
continue;
}
const [existing] = await db
.select({ id: roleModelRoutes.id })
.from(roleModelRoutes)
.where(
and(
eq(roleModelRoutes.workspaceId, workspaceId),
eq(roleModelRoutes.roleId, roleId),
),
)
.limit(1);
if (existing) {
skipped++;
continue;
}
await db.insert(roleModelRoutes).values({
workspaceId,
roleId,
providerAliasId: providerId,
sortOrder: sortOrder++,
});
inserted++;
}
return { inserted, skipped };
}

View file

@ -67,6 +67,10 @@ export const agentRuns = pgTable(
tokensTotal: integer("tokens_total"),
notes: text("notes"),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
modelAlias: varchar("model_alias", { length: 120 }),
providerType: varchar("provider_type", { length: 40 }),
modelId: varchar("model_id", { length: 200 }),
modelTier: varchar("model_tier", { length: 20 }),
},
(table) => ({
workspaceStartedIdx: index("agent_runs_workspace_id_started_at_idx").on(

View file

@ -13,3 +13,5 @@ export * from "./markdown_backlog";
export * from "./cursor_sync";
export * from "./audit";
export * from "./agent_runs";
export * from "./model_providers";
export * from "./role_model_routes";

View file

@ -0,0 +1,51 @@
import {
boolean,
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
integer,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
/** Workspace-scoped model provider alias — secrets live in env vars, not here. */
export const modelProviders = pgTable(
"model_providers",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
alias: varchar("alias", { length: 120 }).notNull(),
displayName: varchar("display_name", { length: 200 }).notNull(),
providerType: varchar("provider_type", { length: 40 }).notNull(),
modelId: varchar("model_id", { length: 200 }).notNull(),
baseUrl: text("base_url"),
apiKeyEnvVar: varchar("api_key_env_var", { length: 120 }),
extraHeaders: jsonb("extra_headers").$type<Record<string, string> | null>(),
defaultParams: jsonb("default_params").$type<{
temperature?: number;
maxTokens?: number;
} | null>(),
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdIdx: index("model_providers_workspace_id_idx").on(table.workspaceId),
workspaceAliasUnique: uniqueIndex("model_providers_workspace_id_alias_unique").on(
table.workspaceId,
table.alias,
),
}),
);

View file

@ -19,6 +19,8 @@ import { cursorSyncMappings } from "./cursor_sync";
import { workspaces, workspaceInvites } from "./workspaces";
import { auditLog } from "./audit";
import { agentRuns } from "./agent_runs";
import { modelProviders } from "./model_providers";
import { roleModelRoutes } from "./role_model_routes";
export const objectRelations = pgTable(
"object_relations",
@ -116,6 +118,8 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
objectTypeDefs: many(objectTypeDefs),
propertyDefinitions: many(propertyDefinitions),
markdownBacklogItems: many(markdownBacklogItems),
modelProviders: many(modelProviders),
roleModelRoutes: many(roleModelRoutes),
}));
export const objectsRelations = relations(objects, ({ one, many }) => ({
@ -264,3 +268,22 @@ export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one
references: [markdownBacklogItems.id],
}),
}));
export const modelProvidersRelations = relations(modelProviders, ({ one, many }) => ({
workspace: one(workspaces, {
fields: [modelProviders.workspaceId],
references: [workspaces.id],
}),
roleRoutes: many(roleModelRoutes),
}));
export const roleModelRoutesRelations = relations(roleModelRoutes, ({ one }) => ({
workspace: one(workspaces, {
fields: [roleModelRoutes.workspaceId],
references: [workspaces.id],
}),
provider: one(modelProviders, {
fields: [roleModelRoutes.providerAliasId],
references: [modelProviders.id],
}),
}));

View file

@ -0,0 +1,45 @@
import {
boolean,
index,
integer,
pgTable,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
import { modelProviders } from "./model_providers";
/** Maps pipeline role_id strings to a workspace provider alias row. */
export const roleModelRoutes = pgTable(
"role_model_routes",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
roleId: varchar("role_id", { length: 120 }).notNull(),
providerAliasId: uuid("provider_alias_id")
.notNull()
.references(() => modelProviders.id, { onDelete: "cascade" }),
enabled: boolean("enabled").notNull().default(true),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
workspaceIdIdx: index("role_model_routes_workspace_id_idx").on(table.workspaceId),
workspaceRoleUnique: uniqueIndex(
"role_model_routes_workspace_id_role_id_unique",
).on(table.workspaceId, table.roleId),
providerAliasIdIdx: index("role_model_routes_provider_alias_id_idx").on(
table.providerAliasId,
),
}),
);

View file

@ -7,6 +7,7 @@
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./model-routing": "./src/model-routing/index.ts",
"./types": "./src/types/index.ts",
"./utils": "./src/utils/index.ts"
},

View file

@ -1,2 +1,3 @@
export * from "./model-routing";
export * from "./types";
export * from "./utils";

View file

@ -0,0 +1,28 @@
/**
* Shipped pipeline defaults — alias *names*, not provider UUIDs. Operators remap
* aliases in Settings; these are fallbacks when no workspace route exists.
*/
export const PIPELINE_DEFAULT_ROLE_ROUTES: Readonly<Record<string, string>> = {
"role-conductor": "claude-planning",
"role-architect": "claude-planning",
"role-ia-architect": "claude-planning",
"role-ux-reviewer": "qwen-coder",
"role-implementer": "qwen-coder",
"role-reviewer": "qwen-coder",
"role-design-system-auditor": "qwen-coder",
"role-a11y-auditor": "qwen-coder",
"role-doc-writer": "qwen-coder",
"gate-copilot": "claude-planning",
"chat-default": "qwen-coder",
};
/** Cursor-era model slugs → registry alias names (workspace may override). */
export const LEGACY_CURSOR_MODEL_ALIASES: Readonly<Record<string, string>> = {
"claude-4.6-opus-high-thinking": "claude-planning",
"claude-4.6-sonnet-medium-thinking": "claude-planning",
"claude-opus-4-8-thinking-high": "claude-planning",
"claude-sonnet-5-thinking-medium": "claude-planning",
"composer-2.5-fast": "qwen-coder",
"composer-2.5": "qwen-coder",
auto: "qwen-coder",
};

View file

@ -0,0 +1,28 @@
export {
LEGACY_CURSOR_MODEL_ALIASES,
PIPELINE_DEFAULT_ROLE_ROUTES,
} from "./constants";
export {
convoyModelPolicySchema,
modelProviderSchema,
modelTierSchema,
providerTypeSchema,
resolvedModelSchema,
roleModelRouteSchema,
type ConvoyModelPolicy,
type ModelProvider,
type ModelTier,
type ProviderType,
type ResolvedModel,
type RoleModelRoute,
} from "./schemas";
export {
resolveModel,
resolveModelErrorCodeSchema,
type ResolveModelError,
type ResolveModelErrorCode,
type ResolveModelFailure,
type ResolveModelInput,
type ResolveModelResult,
type ResolveModelSuccess,
} from "./resolve-model";

View file

@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import { LEGACY_CURSOR_MODEL_ALIASES } from "./constants";
import type { ModelProvider, RoleModelRoute } from "./schemas";
import { resolveModel } from "./resolve-model";
const WORKSPACE_ID = "379ecb2f-6db9-4c53-93ad-72c594eb44a5";
function provider(
overrides: Partial<ModelProvider> & Pick<ModelProvider, "alias" | "providerType" | "modelId">,
): ModelProvider {
return {
id: overrides.id ?? "11111111-1111-1111-1111-111111111111",
workspaceId: WORKSPACE_ID,
displayName: overrides.displayName ?? overrides.alias,
enabled: true,
...overrides,
};
}
const claude = provider({
id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
alias: "claude-planning",
providerType: "anthropic",
modelId: "claude-sonnet-4-20250514",
apiKeyEnvVar: "ANTHROPIC_API_KEY",
metadata: { tier: "premium" },
});
const qwen = provider({
id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
alias: "qwen-coder",
providerType: "openai_compatible",
modelId: "qwen2.5-coder:32b",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyEnvVar: "OLLAMA_API_KEY",
metadata: { tier: "local" },
});
const routes: RoleModelRoute[] = [
{
id: "cccccccc-cccc-cccc-cccc-cccccccccccc",
workspaceId: WORKSPACE_ID,
roleId: "role-implementer",
providerAliasId: qwen.id,
enabled: true,
},
];
const getApiKey = (name: string) =>
({ ANTHROPIC_API_KEY: "sk-ant-test", OLLAMA_API_KEY: "ollama" })[name];
describe("resolveModel", () => {
it("prefers brief recommended_model over convoy, workspace, and default", () => {
const result = resolveModel({
roleId: "role-implementer",
providers: [claude, qwen],
routes,
convoyPolicy: { roles: { "role-implementer": "qwen-coder" } },
briefRecommendedModel: "claude-planning",
getApiKey,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.source).toBe("brief");
expect(result.model.alias).toBe("claude-planning");
});
it("prefers convoy policy over workspace route and pipeline default", () => {
const result = resolveModel({
roleId: "role-implementer",
providers: [claude, qwen],
routes,
convoyPolicy: { roles: { "role-implementer": "claude-planning" } },
getApiKey,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.source).toBe("convoy");
expect(result.model.alias).toBe("claude-planning");
});
it("uses workspace route when convoy and brief are absent", () => {
const result = resolveModel({
roleId: "role-implementer",
providers: [claude, qwen],
routes,
getApiKey,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.source).toBe("workspace");
expect(result.model.alias).toBe("qwen-coder");
expect(result.model.tier).toBe("local");
});
it("falls back to pipeline default role routes", () => {
const result = resolveModel({
roleId: "role-architect",
providers: [claude, qwen],
routes: [],
getApiKey,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.source).toBe("default");
expect(result.model.alias).toBe("claude-planning");
});
it("returns ALIAS_NOT_RESOLVED when no layer matches", () => {
const result = resolveModel({
roleId: "role-custom-unknown",
providers: [claude, qwen],
routes: [],
defaultRoutes: {},
getApiKey,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe("ALIAS_NOT_RESOLVED");
});
it("returns PROVIDER_DISABLED when alias maps to a disabled provider", () => {
const result = resolveModel({
roleId: "role-implementer",
providers: [{ ...qwen, enabled: false }],
routes: [],
briefRecommendedModel: "qwen-coder",
getApiKey,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe("PROVIDER_DISABLED");
expect(result.error.alias).toBe("qwen-coder");
});
it("remaps legacy Cursor model slugs before catalog lookup", () => {
const legacySlug = "composer-2.5-fast";
expect(LEGACY_CURSOR_MODEL_ALIASES[legacySlug]).toBe("qwen-coder");
const result = resolveModel({
roleId: "role-implementer",
providers: [qwen],
routes: [],
briefRecommendedModel: legacySlug,
getApiKey,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.resolvedAlias).toBe("qwen-coder");
});
it("returns API_KEY_MISSING when required env var is absent", () => {
const result = resolveModel({
roleId: "role-architect",
providers: [claude],
routes: [],
getApiKey: () => undefined,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.code).toBe("API_KEY_MISSING");
});
});

View file

@ -0,0 +1,213 @@
import {
LEGACY_CURSOR_MODEL_ALIASES,
PIPELINE_DEFAULT_ROLE_ROUTES,
} from "./constants";
import type {
ConvoyModelPolicy,
ModelProvider,
ModelTier,
ResolvedModel,
RoleModelRoute,
} from "./schemas";
import { resolvedModelSchema } from "./schemas";
export const resolveModelErrorCodeSchema = [
"ALIAS_NOT_RESOLVED",
"PROVIDER_NOT_FOUND",
"PROVIDER_DISABLED",
"BASE_URL_REQUIRED",
"API_KEY_MISSING",
] as const;
export type ResolveModelErrorCode = (typeof resolveModelErrorCodeSchema)[number];
export type ResolveModelError = {
code: ResolveModelErrorCode;
message: string;
alias?: string;
roleId: string;
};
export type ResolveModelSuccess = {
ok: true;
model: ResolvedModel;
source: "brief" | "convoy" | "workspace" | "default";
resolvedAlias: string;
};
export type ResolveModelFailure = {
ok: false;
error: ResolveModelError;
};
export type ResolveModelResult = ResolveModelSuccess | ResolveModelFailure;
export type ResolveModelInput = {
roleId: string;
providers: ModelProvider[];
routes: RoleModelRoute[];
convoyPolicy?: ConvoyModelPolicy;
briefRecommendedModel?: string;
/** Workspace metadata `legacy_model_aliases` — checked after global legacy map. */
legacyAliases?: Record<string, string>;
getApiKey: (envVarName: string) => string | undefined;
defaultRoutes?: Readonly<Record<string, string>>;
};
function normalizeAlias(
rawAlias: string,
legacyAliases?: Record<string, string>,
): string {
const trimmed = rawAlias.trim();
return (
legacyAliases?.[trimmed] ??
LEGACY_CURSOR_MODEL_ALIASES[trimmed] ??
trimmed
);
}
function readTier(metadata: ModelProvider["metadata"]): ModelTier | undefined {
if (!metadata || typeof metadata !== "object") return undefined;
const tier = (metadata as { tier?: unknown }).tier;
if (
tier === "premium" ||
tier === "fast" ||
tier === "local" ||
tier === "auto"
) {
return tier;
}
return undefined;
}
function pickAlias(
input: ResolveModelInput,
): { alias: string; source: ResolveModelSuccess["source"] } | null {
const brief = input.briefRecommendedModel?.trim();
if (brief) {
return { alias: brief, source: "brief" };
}
const convoyAlias = input.convoyPolicy?.roles?.[input.roleId]?.trim();
if (convoyAlias) {
return { alias: convoyAlias, source: "convoy" };
}
const route = input.routes.find(
(row) => row.roleId === input.roleId && row.enabled,
);
if (route) {
const provider = input.providers.find((p) => p.id === route.providerAliasId);
if (provider?.alias) {
return { alias: provider.alias, source: "workspace" };
}
}
const defaults = input.defaultRoutes ?? PIPELINE_DEFAULT_ROLE_ROUTES;
const fallback = defaults[input.roleId]?.trim();
if (fallback) {
return { alias: fallback, source: "default" };
}
return null;
}
function fail(
code: ResolveModelErrorCode,
message: string,
roleId: string,
alias?: string,
): ResolveModelFailure {
return {
ok: false,
error: { code, message, roleId, alias },
};
}
/**
* Pure resolver — callers load workspace provider/route rows and pass plain
* objects. Resolution order: brief → convoy → workspace route → pipeline default.
*/
export function resolveModel(input: ResolveModelInput): ResolveModelResult {
const picked = pickAlias(input);
if (!picked) {
return fail(
"ALIAS_NOT_RESOLVED",
`No model alias resolved for role "${input.roleId}"`,
input.roleId,
);
}
const resolvedAlias = normalizeAlias(picked.alias, input.legacyAliases);
const provider = input.providers.find((p) => p.alias === resolvedAlias);
if (!provider) {
return fail(
"PROVIDER_NOT_FOUND",
`Provider alias "${resolvedAlias}" is not registered in this workspace`,
input.roleId,
resolvedAlias,
);
}
if (!provider.enabled) {
return fail(
"PROVIDER_DISABLED",
`Provider alias "${resolvedAlias}" is disabled`,
input.roleId,
resolvedAlias,
);
}
if (provider.providerType === "openai_compatible" && !provider.baseUrl) {
return fail(
"BASE_URL_REQUIRED",
`Provider "${resolvedAlias}" requires baseUrl for openai_compatible`,
input.roleId,
resolvedAlias,
);
}
let apiKey = "";
if (provider.apiKeyEnvVar) {
const value = input.getApiKey(provider.apiKeyEnvVar);
if (value === undefined || value.length === 0) {
return fail(
"API_KEY_MISSING",
`Environment variable "${provider.apiKeyEnvVar}" is not set for provider "${resolvedAlias}"`,
input.roleId,
resolvedAlias,
);
}
apiKey = value;
}
const model: ResolvedModel = {
alias: resolvedAlias,
providerType: provider.providerType,
modelId: provider.modelId,
apiKey,
...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
...(provider.extraHeaders ? { extraHeaders: provider.extraHeaders } : {}),
...(provider.defaultParams ? { defaultParams: provider.defaultParams } : {}),
...(readTier(provider.metadata)
? { tier: readTier(provider.metadata) }
: {}),
};
const parsed = resolvedModelSchema.safeParse(model);
if (!parsed.success) {
return fail(
"PROVIDER_NOT_FOUND",
parsed.error.issues.map((i) => i.message).join("; "),
input.roleId,
resolvedAlias,
);
}
return {
ok: true,
model: parsed.data,
source: picked.source,
resolvedAlias,
};
}

View file

@ -0,0 +1,74 @@
import { z } from "zod";
export const providerTypeSchema = z.enum([
"anthropic",
"openai",
"google",
"openai_compatible",
"azure_openai",
]);
export type ProviderType = z.infer<typeof providerTypeSchema>;
export const modelTierSchema = z.enum(["premium", "fast", "local", "auto"]);
export type ModelTier = z.infer<typeof modelTierSchema>;
export const modelProviderSchema = z.object({
id: z.string().uuid(),
workspaceId: z.string().uuid(),
alias: z.string().min(1),
displayName: z.string().min(1),
providerType: providerTypeSchema,
modelId: z.string().min(1),
baseUrl: z.string().url().nullable().optional(),
apiKeyEnvVar: z.string().min(1).nullable().optional(),
extraHeaders: z.record(z.string()).nullable().optional(),
defaultParams: z
.object({
temperature: z.number().optional(),
maxTokens: z.number().optional(),
})
.nullable()
.optional(),
metadata: z.record(z.unknown()).nullable().optional(),
enabled: z.boolean().default(true),
});
export type ModelProvider = z.infer<typeof modelProviderSchema>;
export const roleModelRouteSchema = z.object({
id: z.string().uuid(),
workspaceId: z.string().uuid(),
roleId: z.string().min(1),
providerAliasId: z.string().uuid(),
enabled: z.boolean().default(true),
sortOrder: z.number().int().optional(),
});
export type RoleModelRoute = z.infer<typeof roleModelRouteSchema>;
export const resolvedModelSchema = z.object({
alias: z.string().min(1),
providerType: providerTypeSchema,
modelId: z.string().min(1),
baseUrl: z.string().url().optional(),
apiKey: z.string(),
extraHeaders: z.record(z.string()).optional(),
defaultParams: z
.object({
temperature: z.number().optional(),
maxTokens: z.number().optional(),
})
.optional(),
tier: modelTierSchema.optional(),
});
export type ResolvedModel = z.infer<typeof resolvedModelSchema>;
export const convoyModelPolicySchema = z.object({
roles: z.record(z.string()).optional(),
escalate_to: z.string().min(1).optional(),
});
export type ConvoyModelPolicy = z.infer<typeof convoyModelPolicySchema>;

View file

@ -0,0 +1,59 @@
---
kind: epic
slug: apps-orchestrator
title: apps/orchestrator — role dispatch and gate pauses
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Long-running **`apps/orchestrator`** service: Redis job queue, convoy state machine driver, role dispatcher. Inspired by Symphony **concepts** (stall detection, token logging) — **not** Symphony codebase.
On **Go**: enqueue planning roles serially (ia → ux → architect), stop at `convoy_review`; after Gate 1, dispatch implementer jobs (parallel when `slice_dependencies` allow); fan-out audit jobs; stop at `pr_review` and post-merge gates.
**Parallel implementer fan-out:** When multiple implementer jobs target the same Mac inference host, orchestrator should pass the same `qwen-coder-mlx` alias to each job; operator must run a batching-capable MLX server (mlx-openai-server) — see `docs/adr-model-routing.md` § Local MLX. Cap concurrent implementers per host in orchestrator config if OOM occurs (agent-pipeline multitask playbook).
Each job calls `resolveModel(workspace, roleId, convoy, brief)` and passes `ResolvedModel` to worker.
## In scope / out of scope
**In scope**
- `apps/orchestrator` package in monorepo.
- Redis queue (existing infra).
- Deploy on Coolify beside `web`.
- Stall detection → `agent_runs` / convoy `blocked`.
**Out of scope**
- Worker implementation (Epic 7).
- Symphony / Linear integration.
## Dependencies
- Epic 1 (`resolveModel`).
- Epic 3 (repo link for clone path).
- Epic 4 (transitions).
- Epic 5 (gates — for dogfood; orchestrator can run in dry-run before UI).
## Blocks
- Epic 7 (worker consumes jobs).
## Acceptance criteria
- [ ] Dry-run: orchestrator logs next role for a convoy without worker.
- [ ] Live: one implementer brief dispatched and paused correctly at gate.
## Tasks
_To be decomposed when epic graduates to `ready`._
## Note
Supersedes [Epic-optional-orchestrator](../../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) deferred stub with a concrete Echodo-native design.

View file

@ -0,0 +1,49 @@
---
kind: epic
slug: apps-worker-v1
title: apps/worker v1 — minimal Echodo coding agent
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
First-party **`apps/worker`**: minimal agent runtime in the Echodo monorepo (not OpenHands). Docker sandbox per job, tool loop (`read_file`, `write_file`, `grep`, `run_command` with allowlist), git worktree per brief, MCP `claim_task` / `complete_task`, inference via `ResolvedModel` from orchestrator.
Role prompt = `.cursor/agents/role-*.md` + brief path. Model endpoint points at operator Mac (**Ollama or MLX server** — see ADR § Local MLX) or cloud APIs per registry.
**Parallel implementers:** When orchestrator dispatches N implementers to the same Mac inference host, prefer a batching-capable MLX server (**mlx-openai-server**) over `mlx_lm.server` alone. Document chosen server + port in workspace provider alias (`qwen-coder-mlx`).
## In scope / out of scope
**In scope**
- `WorkerSession` adapter interface (for future optional OpenHands plug-in).
- `pnpm lint && pnpm test` in allowlisted commands.
- Branch + PR draft output (forge adapter from Epic 3).
**Out of scope**
- Browser automation, multi-agent delegation.
- Feature parity with OpenHands.
## Dependencies
- Epic 1, 3, 6.
## Blocks
- Epic 8 (meaningful PR webhook dogfood).
## Acceptance criteria
- [ ] One brief from `.convoys/` completes unattended on connected repo.
- [ ] `complete_task` includes model alias + token totals.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,48 @@
---
kind: epic
slug: connected-repos
title: Connected repos — paste remote URL, forge adapters
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Workspace admins connect git remotes (Forgejo, GitHub, GitLab, Bitbucket, future Origin) via **paste URL** UX. Store `repo_links` with `forge_type`, API base, and credentials reference. Provide a `ForgeAdapter` interface for clone metadata, open PR, and (later) webhooks.
See automation foundation discussion: Vercel/Coolify-style “show me your repo.”
## In scope / out of scope
**In scope**
- `connected_repos` (or `repo_links`) Drizzle table, workspace-scoped.
- tRPC CRUD + “test clone” (shallow fetch).
- Settings UI: connect / disconnect / list.
- Forge adapter trait + implementations: `forgejo`, `github` (minimum two).
**Out of scope**
- Webhook receivers (Epic 8).
- Orchestrator clone logic (Epic 6 consumes this table).
## Dependencies
- Workspace admin auth patterns (existing).
## Blocks
- Epic 6 (orchestrator), Epic 7 (worker).
## Acceptance criteria
- [ ] Admin pastes Forgejo URL; Echodo stores link and detects `forge_type`.
- [ ] Worker/orchestrator can read `git_remote_url` + default branch from API.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,52 @@
---
kind: epic
slug: convoy-lifecycle-mcp
title: Convoy lifecycle MCP — state machine and convoy_events
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Implement agent-pipeline **Phase 2a** convoy lifecycle in Echodo MCP: `create_convoy`, `create_brief`, `transition_convoy_status`, `log_convoy_event`, `query_manifest_status`, `reconcile_from_files`.
Server-enforced state machine (draft → convoy_review → ready → in_progress → pr_review → merged → released). Human gate actors (`human-gate-1/2/3`) only for their transitions.
Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending-mcp-sync.jsonl` outbox.
## In scope / out of scope
**In scope**
- MCP tools in `apps/mcp-server`.
- `convoy_events` table (or extend `agent_runs` pattern).
- Map convoy → Echodo `project` object; brief → `task` child (per v0.4 object mapping).
- Extended status enum documented; UI filters updated in Epic 5.
**Out of scope**
- Orchestrator auto-dispatch (Epic 6).
- Deliverable templates (agent-pipeline Phase 2b).
## Dependencies
- Epic 2 (convoy file conventions).
- Epic 3 (workspace `convoys-<repo>` convention).
## Blocks
- Epic 5, 6, 9.
## Acceptance criteria
- [ ] Invalid transition `draft → released` rejected with structured error.
- [ ] `role-architect` cannot call `human-gate-1` transition.
- [ ] Offline convoy edit reconciles via `reconcile_from_files`.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,42 @@
---
kind: epic
slug: fleet-dashboard
title: Fleet dashboard — all connected repos and convoys
plan_slug: automation-foundation
status: draft
priority: P2
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Top-level **fleet** view: all `convoys-<repo>` workspaces (or connected repos) in one screen. Roll up convoys by status, gate inbox count, active `agent_runs`, recent spend by model alias.
Answers: “What needs me across all projects?”
## In scope / out of scope
**In scope**
- Route `/fleet` or home dashboard for operator account.
- Cross-workspace tRPC (membership-scoped).
- Links into per-repo mission control (Epic 5).
**Out of scope**
- Multi-tenant fleet across unrelated customers (operator's own repos only for v1).
## Dependencies
- Epic 4 (queryable convoy status).
- Epic 5 (gate semantics).
## Acceptance criteria
- [ ] Operator with two connected repos sees combined “3 awaiting approval” badge.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,39 @@
---
kind: epic
slug: forge-webhooks
title: Forge webhooks — PR and CI events drive convoy transitions
plan_slug: automation-foundation
status: draft
priority: P2
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Webhook receivers on Echodo for connected forges: PR opened → `pr_review`; PR merged → trigger doc-writer job + `merged`; CI failed → optional `blocked` or notify. Supports Forgejo and GitHub adapters minimum.
## In scope / out of scope
**In scope**
- `POST /api/webhooks/forge/:repoLinkId` with signature verification.
- Map events to `transition_convoy_status(actor: automation)`.
- Link PR branch to convoy/brief via branch naming convention or PR body marker.
**Out of scope**
- Replacing Coolify deploy webhooks for Gate 3 prod promote (manual or separate integration).
## Dependencies
- Epic 3, 4, 7.
## Acceptance criteria
- [ ] Merge on Forgejo moves convoy from `pr_review` to `merged` when Gate 2 approved.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,49 @@
---
kind: epic
slug: mission-control-gates-ui
title: Mission control — convoy kanban, gate inbox, co-pilot
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Echodo UI for human gates and convoy visibility:
- **Convoy kanban** — columns map to state machine statuses (Planning, Awaiting plan approval, Building, …).
- **Gate inbox** — “needs you” queue for `convoy_review`, `pr_review`, pre-`released`.
- **Approve / deny / request changes** — calls `transition_convoy_status` with correct `human-gate-*` actor.
- **Gate co-pilot** — AI panel using `gate-copilot` role route (`resolveModel`) to summarize plan/diff and suggest action.
## In scope / out of scope
**In scope**
- Routes under `/{workspaceSlug}/convoys/` or `/mission-control/`.
- Conductor-style chat to seed convoys (can call `create_convoy` MCP or web tRPC wrapper).
**Out of scope**
- Fleet cross-repo view (Epic 9).
- Unattended orchestration (Epic 6).
## Dependencies
- Epic 4 (lifecycle transitions and queryable convoy state).
## Blocks
- Epic 6 (orchestrator pauses until gate UI exists for dogfood).
## Acceptance criteria
- [ ] Operator sees pending Gate 1 convoy and approves → status `ready`.
- [ ] Co-pilot uses `claude-planning` alias when configured.
## Tasks
_To be decomposed when epic graduates to `ready`._

View file

@ -0,0 +1,88 @@
---
kind: epic
slug: model-provider-registry
title: Model provider registry and per-role routing
plan_slug: automation-foundation
status: done
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
agent_prompt: |
Epic: model provider registry (docs/adr-model-routing.md). Server-only change —
no UI component library inventing; reuse components/ui/. Admin-only mutations.
Filter every DB query by workspace_id. One task per session; claim_task first.
---
# Epic objective
Deliver the workspace-scoped **model provider registry** and **role → alias routing** described in [docs/adr-model-routing.md](../../../docs/adr-model-routing.md): Drizzle schema, zod types in `packages/shared`, `resolveModel()`, tRPC CRUD + `testProvider`, Settings UI, and `agent_runs` model metadata on completion.
This epic is the first shipped piece of the automation-foundation plan and unblocks future orchestrator/worker work.
## In scope / out of scope
**In scope**
- Tables `model_providers`, `role_model_routes`.
- Pure resolver + unit tests.
- tRPC router (workspace admin).
- Settings pages: Providers, Role routing.
- Seed/default routes for nine L2 roles + `gate-copilot` + `chat-default`.
- `complete_task` optional model logging fields.
- `.env.example` env var documentation.
**Out of scope**
- Orchestrator dispatch (consumes resolver later).
- Worker inference loop.
- Parsing `model_policy` from `.convoys/*.md` files (orchestrator reads later).
- Billing/budget enforcement.
## Related tasks
| Task | Link |
|------|------|
| Shared schemas and resolveModel | `./Task-shared-schemas-resolve-model.md` |
| DB schema and migration | `./Task-db-model-providers-migration.md` |
| tRPC models router | `./Task-trpc-models-router.md` |
| Settings — providers UI | `./Task-settings-ai-providers-ui.md` |
| Settings — role routing UI | `./Task-settings-role-routing-ui.md` |
| agent_runs model fields + MCP | `./Task-agent-runs-model-logging.md` |
## Dependencies
- Depends on: existing auth + workspace admin patterns in `apps/web`.
- Blocks: orchestrator epic (needs `resolveModel`), worker epic (needs `ResolvedModel`).
## Acceptance criteria
- [ ] Admin can create `claude-planning` (anthropic) and `qwen-coder` (openai_compatible + base URL) in a workspace.
- [ ] Admin can map `role-implementer` → `qwen-coder`, `role-architect` → `claude-planning`.
- [ ] `testProvider` returns success/failure without exposing API keys.
- [ ] `resolveModel()` unit tests cover override order and missing-alias error.
- [ ] At least one task in this epic completed via `claim_task` → `complete_task` with model metadata.
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| Schema + resolver | Session 1–2 | Tasks: shared schemas, migration |
| API | Session 3 | tRPC router |
| UI | Session 4–5 | Both settings pages |
| Logging | Session 6 | agent_runs + MCP |
## Pipeline / convoy mapping
When running via agent-pipeline, architect briefs should align to tasks:
| Brief # | Task slug |
|---------|-----------|
| 1 | `shared-schemas-resolve-model` |
| 2 | `db-model-providers-migration` |
| 3 | `trpc-models-router` |
| 4 | `settings-ai-providers-ui` |
| 5 | `settings-role-routing-ui` |
| 6 | `agent-runs-model-logging` |
Briefs 1–2 serial (schema depends on types); 4–5 can fan out after brief 3 if `files:` are disjoint.

View file

@ -0,0 +1,77 @@
---
kind: task
slug: agent-runs-model-logging
title: agent_runs model metadata and complete_task logging fields
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md. Extend agent_runs schema + complete_task MCP +
runs UI columns. Migration via db:generate. claim_task before work;
complete_task when done — include modelAlias on your own completion as
dogfood proof.
---
# Task summary
Persist model routing metadata on `agent_runs` and accept optional model fields on `complete_task` so dogfooded pipeline sessions record which alias was used.
## Description
### DB (`agent_runs`)
Add nullable columns:
- `model_alias` varchar
- `provider_type` varchar
- `model_id` varchar
- `model_tier` varchar
Migration via `pnpm db:generate`.
### MCP `complete_task`
Extend zod input (optional):
- `modelAlias?: string`
- `modelTier?: string`
- `providerType?: string`
- `modelId?: string`
Write to `agent_runs` on close. Document in tool description.
### Runs UI
`apps/web/app/(app)/[workspaceSlug]/settings/runs/page.tsx` — show model alias column in table and expanded row detail.
### `.env.example`
Add commented entries: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY`, `OLLAMA_API_KEY`, `OPENAI_BASE_URL` with note pointing to ADR.
## Subtasks
- [ ] Schema + migration
- [ ] complete_task schema + handler
- [ ] Runs UI column
- [ ] .env.example update
## Estimation
S
## Acceptance criteria
- [ ] Completing a run with `modelAlias: "qwen-coder"` persists and displays in runs UI
- [ ] Existing complete_task callers unchanged (fields optional)
- [ ] ADR logging contract satisfied
## Links
- Epic: `./Epic-model-provider-registry.md`
- Plan: `../Plan-automation-foundation.md`
- Related: `plans/Plan-agent-coordination/`

View file

@ -0,0 +1,60 @@
---
kind: task
slug: db-model-providers-migration
title: Drizzle schema and migration for model_providers and role_model_routes
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md and packages/shared model-routing types from
Task-shared-schemas-resolve-model. packages/database only. Generate migration
with pnpm db:generate — do not hand-edit SQL. claim_task before work.
---
# Task summary
Add `model_providers` and `role_model_routes` tables with workspace scoping and indexes per the ADR.
## Description
### Schema (`packages/database/src/schema/`)
- `model_providers.ts` — columns from ADR; unique `(workspace_id, alias)`
- `role_model_routes.ts` — FK to `model_providers`; unique `(workspace_id, role_id)`
- Re-export from `schema/index.ts`; add relations if needed
### Migration
- `pnpm db:generate` from repo root after schema edit
- Commit generated SQL under `packages/database/migrations/`
### Optional seed helper
- `seedDefaultRoleRoutes(workspaceId)` in database package or web server — inserts pipeline default role_ids pointing at aliases **only if** matching aliases exist (no auto-create of providers)
## Subtasks
- [x] Define Drizzle tables
- [x] Generate and commit migration
- [x] Verify `workspace_id` index on both tables
## Estimation
S
## Acceptance criteria
- [x] Migration applies cleanly with `pnpm db:migrate`
- [x] Every query example in epic doc filters by `workspace_id`
- [x] Types align with `packages/shared` zod schemas
## Links
- Epic: `./Epic-model-provider-registry.md`
- ADR: `../../../docs/adr-model-routing.md`
- Depends on: `Task-shared-schemas-resolve-model.md`

View file

@ -0,0 +1,63 @@
---
kind: task
slug: settings-ai-providers-ui
title: Settings UI — AI model providers
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md. apps/web UI only — reuse components/ui.
Route: /[workspaceSlug]/settings/ai/providers. Do not store secrets in
forms — only api_key_env_var name field. claim_task before work.
---
# Task summary
Build the workspace Settings page for CRUD on model provider aliases, including a “Test connection” action.
## Description
### Route
`apps/web/app/(app)/[workspaceSlug]/settings/ai/providers/page.tsx`
Add nav link under Settings (near runs/audit).
### UI
- Table: alias, display name, provider type, model id, base URL (truncated), enabled, actions
- Add/Edit dialog: all fields from ADR; provider type select drives whether base URL is required
- Test button → calls `ai.models.testProvider` → toast with latency or error
- Delete with confirm
### UX notes
- Show helper text: “API key is read from server env var `ANTHROPIC_API_KEY`” — not a password field
- Example presets (optional buttons): “Ollama local”, “MLX local (mlx-openai-server)”, “Anthropic”, “NVIDIA NIM”, “Gemini” — prefill form per `docs/adr-model-routing.md` § Provider examples
## Subtasks
- [ ] Page + list query
- [ ] Create/edit dialog
- [ ] Test connection + delete
- [ ] Settings nav entry
## Estimation
M
## Acceptance criteria
- [ ] Admin can add `qwen-coder` with Mac Ollama base URL and test it
- [ ] Non-admin sees read-only or hidden mutations
- [ ] Matches existing settings page visual patterns (e.g. runs page)
## Links
- Epic: `./Epic-model-provider-registry.md`
- Depends on: `Task-trpc-models-router.md`

View file

@ -0,0 +1,61 @@
---
kind: task
slug: settings-ai-role-routing-ui
title: Settings UI — per-role model routing
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md. apps/web UI. Route:
/[workspaceSlug]/settings/ai/role-routing. Allow adding custom role_id
strings. claim_task before work.
---
# Task summary
Build the Settings page that maps `role_id` → provider alias, with pipeline defaults seeded and support for custom roles.
## Description
### Route
`apps/web/app/(app)/[workspaceSlug]/settings/ai/role-routing/page.tsx`
### UI
- Table: role id, display label (optional friendly name), provider alias (dropdown from `listProviders`), tier badge from provider metadata, enabled toggle
- “Add role” — free-text `role_id` + alias dropdown (for `gate-copilot`, custom auditors, etc.)
- “Reset to pipeline defaults” — calls upsert for nine L2 roles + `gate-copilot` + `chat-default` **if** matching aliases exist; confirm dialog
- Link to ADR / note about convoy `model_policy` overrides
### Default labels
Map known `role_id` to human labels (Conductor, Architect, …) in a const — custom roles show raw id.
## Subtasks
- [ ] Page + list routes query
- [ ] Upsert/disable per row
- [ ] Add custom role flow
- [ ] Reset defaults action
- [ ] Settings nav entry
## Estimation
M
## Acceptance criteria
- [ ] User can set `role-architect` → `claude-planning`, `role-implementer` → `qwen-coder`
- [ ] User can add `gate-copilot` role row
- [ ] Dropdown only lists enabled providers for the workspace
## Links
- Epic: `./Epic-model-provider-registry.md`
- Depends on: `Task-trpc-models-router.md`, `Task-settings-ai-providers-ui.md` (providers should exist first for dogfood; soft dependency)

View file

@ -0,0 +1,65 @@
---
kind: task
slug: shared-schemas-resolve-model
title: Shared zod schemas and resolveModel() pure function
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md first. Implement only packages/shared (and
packages/ai if needed for zero DB imports in shared). No UI, no migration.
claim_task this backlog item before coding; complete_task when done.
---
# Task summary
Add zod schemas and types for model providers, role routes, and a pure `resolveModel()` function per the ADR.
## Description
### Deliverables
1. **`packages/shared`** — export:
- `providerTypeSchema` enum: `anthropic` | `openai` | `google` | `openai_compatible` | `azure_openai`
- `modelProviderSchema`, `roleModelRouteSchema`, `resolvedModelSchema`
- `resolveModel(input)` — pure function; input includes optional brief/convoy overrides, workspace routes, provider catalog
- `PIPELINE_DEFAULT_ROLE_ROUTES` constant (role_id → suggested alias names, not UUIDs)
- `LEGACY_CURSOR_MODEL_ALIASES` map for slug migration
2. **Tests** — `packages/shared/src/model-routing/*.test.ts`:
- Resolution order: brief > convoy > workspace > default
- Missing/disabled alias → structured error
- Legacy slug remapping
### Constraints
- `packages/shared` must not import DB or Node-only APIs.
- Resolver accepts plain objects (tRPC/orchestrator loads rows and passes in).
## Subtasks
- [x] Add schemas and types
- [x] Implement `resolveModel`
- [x] Add vitest cases (≥6 scenarios)
- [x] Export from `packages/shared` index
## Estimation
S (one agent session)
## Acceptance criteria
- [x] `pnpm --filter @tasks/shared test` passes with new tests
- [x] `pnpm type-check` clean
- [x] Resolver matches resolution order in ADR § Architecture
## Links
- Epic: `./Epic-model-provider-registry.md`
- Plan: `../Plan-automation-foundation.md`
- ADR: `../../../docs/adr-model-routing.md`

View file

@ -0,0 +1,66 @@
---
kind: task
slug: trpc-models-router
title: tRPC ai.models router — CRUD, role routes, testProvider
plan_slug: automation-foundation
epic_slug: model-provider-registry
status: done
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-07-03"
agent_prompt: |
Read docs/adr-model-routing.md. apps/web/server only. Admin-only mutations
(match updateWorkflowPrompt bar). testProvider runs a minimal completion
server-side — never return API keys to client. claim_task before work.
---
# Task summary
Add `ai.models` tRPC router: provider CRUD, role route upsert/delete, `testProvider`, and `resolveForRole` debug procedure.
## Description
### Procedures
| Procedure | Auth | Notes |
|-----------|------|-------|
| `listProviders` | member | workspace-scoped |
| `createProvider` / `updateProvider` / `deleteProvider` | admin | zod validate; unique alias |
| `testProvider` | admin | one short completion; return `{ ok, latencyMs, error? }` |
| `listRoleRoutes` | member | join provider alias for display |
| `upsertRoleRoute` / `deleteRoleRoute` | admin | custom `roleId` allowed |
| `resolveForRole` | member | calls shared `resolveModel` with DB rows |
### Model client factory
- Add `packages/ai/src/providers/resolve-client.ts` (or similar): given `ResolvedModel`, return Vercel AI SDK model instance for `generateText` in `testProvider`
- Support at least: `anthropic`, `openai`, `google`, `openai_compatible`
### Wire router
- Register in `apps/web/server/root.ts`
- Export input schemas from shared where possible
## Subtasks
- [ ] Implement provider CRUD
- [ ] Implement role route CRUD
- [ ] Implement testProvider with provider factory
- [ ] Implement resolveForRole
## Estimation
M
## Acceptance criteria
- [ ] Admin can create provider via tRPC; non-admin gets FORBIDDEN
- [ ] testProvider works against env-configured Anthropic or Ollama when aliases exist
- [ ] `pnpm lint && pnpm type-check` clean
## Links
- Epic: `./Epic-model-provider-registry.md`
- Depends on: `Task-db-model-providers-migration.md`, `Task-shared-schemas-resolve-model.md`

View file

@ -0,0 +1,46 @@
---
kind: epic
slug: pipeline-convoy-scaffolding
title: Pipeline convoy scaffolding — .convoys/ and metrics in tasks repo
plan_slug: automation-foundation
status: draft
priority: P1
tenant_id: global
cursor_epic_id: null
updated_at: "2026-07-03"
---
# Epic objective
Install L3 convoy infrastructure in the `tasks` repo so agent-pipeline dogfooding works: `.convoys/README.md`, `scripts/log-convoy-event.sh`, `.cursor/rules/convoy-planning.mdc`, `.gitignore` entry for `.convoys/.metrics.jsonl`.
Can run **in parallel** with Epic 1 (model provider registry).
## In scope / out of scope
**In scope**
- Copy/adapt from `agent-pipeline` L3 `_common` templates (manifest-aware paths).
- Document kickoff ritual in `.convoys/README.md`.
**Out of scope**
- Convoy lifecycle MCP (Epic 4).
- Creating convoy files for other consumer repos.
## Dependencies
- None (bootstrap from agent-pipeline templates).
## Blocks
- Epic 4 (convoy lifecycle MCP) — operators need a place to write convoy files first.
## Acceptance criteria
- [ ] `bash scripts/log-convoy-event.sh role=role-conductor convoy=test` appends valid JSONL.
- [ ] `role-conductor` can write `.convoys/<slug>.md` without conflicting rules.
## Tasks
_To be decomposed by architect when epic graduates to `ready`._

View file

@ -0,0 +1,228 @@
---
kind: plan
slug: automation-foundation
title: Automation foundation — mission control, model routing, orchestrator path
status: ready
priority: P1
tenant_id: global
cursor_plan_id: null
updated_at: "2026-07-07"
agent_prompt: |
You are implementing Echodo's automation foundation. Read docs/adr-model-routing.md
before touching model routing code. This repo uses pnpm, Drizzle, tRPC, zod in
packages/shared, and multitenancy via workspace_id on every tenant table.
Dogfooding (required for every task in this plan):
1. At session start: call MCP claim_task with this task's backlogItemId (import
plans/ if the row is missing).
2. At session end: call MCP complete_task with outcome, tokensTotal, and
modelAlias/modelTier when you used a routed model.
3. Stay within the task's acceptance criteria; do not widen scope.
4. Run pnpm lint && pnpm type-check && pnpm test before declaring done.
Stack: Next.js App Router, packages/database for schema, apps/web for UI.
Never store API keys in committed files or DB — only api_key_env_var names.
---
# Plan overview
Echodo is evolving from a multitenant task app with MCP coordination into **mission control** for an agent pipeline: fleet view, human gates, per-role model routing, and (later) a built-in orchestrator and worker. This plan delivers the **first concrete slice** — the model provider registry and role routing system defined in [docs/adr-model-routing.md](../../docs/adr-model-routing.md).
Later epics in this plan (2–9) add convoy scaffolding, connected repos, lifecycle MCP, gates UI, orchestrator, worker, webhooks, and fleet dashboard. Epic 1 gives them a stable `resolveModel()` contract.
## Dogfooding the agent pipeline
**Yes — this plan is designed to dogfood the agent-pipeline end to end.**
| Pipeline artifact | How we use it |
|-------------------|---------------|
| L2 roles | Implementation runs as `role-implementer` per task brief (or ad hoc with implementer rules) |
| `claim_task` / `complete_task` | Every task session claims and completes via Echodo MCP |
| `agent_prompt` (plan → epic → task) | First-message context for agents; inheritance from this frontmatter |
| `docs/adr-model-routing.md` | Architect contract — agents read before coding |
| `.convoys/model-routing-registry.md` | **Created at kickoff** via `role-conductor` — tracks IA/UX/architecture for the initiative (not stored under `plans/`) |
| `model_policy` in convoy | Dogfoods alias-based routing (Claude planning, Qwen local) once registry exists |
| `scripts/log-convoy-event.sh` | Log each role completion to `.convoys/.metrics.jsonl` when L3 convoy scaffolding is present |
**Kickoff ritual (human + conductor):**
1. Prompt: *"Start a new convoy: Implement workspace-scoped model provider registry and role routing per docs/adr-model-routing.md. Success = operators can configure Anthropic + Ollama aliases and assign them to pipeline roles from Settings."*
2. Conductor writes `.convoys/model-routing-registry.md` with `classification: server-only` (skip ia, ux, design, a11y).
3. Architect decomposes into briefs that **map 1:1 to tasks in `Epic-model-provider-registry`** (below).
4. Each implementer session: `claim_task` on the matching `Task-*.md` → implement → `complete_task`.
## Getting started (operator checklist)
Do these **before or in parallel with** Epic 1 implementation. Nothing here blocks writing code; some items unblock dogfooding and `testProvider` against real endpoints.
### A. Repo and backlog (one time)
- [ ] `pnpm install` at repo root
- [ ] `.env` from `.env.example` — `DATABASE_URL`, `REDIS_URL`, `NEXTAUTH_*` at minimum
- [ ] `pnpm db:migrate` if schema is behind
- [ ] `pnpm --filter @tasks/database import:markdown-backlog` — loads `plans/Plan-automation-foundation/**` into DB for `claim_task`
- [ ] Confirm Echodo MCP in `.cursor/mcp.json` works (restart Cursor after MCP changes)
### B. Local inference on Mac (pick one or both)
See ADR § **Local MLX on Apple Silicon** and **Local Ollama**.
| Goal | Action |
|------|--------|
| Simplest local coder | Install [Ollama](https://ollama.com), `ollama pull qwen2.5-coder:32b`, listen on LAN if Coolify must reach Mac |
| Best Apple Silicon perf / MLX models | `pip install mlx-openai-server` (preferred if you expect **parallel implementers**), launch with `--host 0.0.0.0` |
| Quick MLX smoke test | `pip install mlx-lm` → `mlx_lm.server --model mlx-community/...` on port 8080 |
Note your Mac LAN IP and port for registry aliases later (`http://<ip>:8000/v1` or `:11434/v1`).
### C. Cloud / free-tier aliases (optional until Epic 1 UI exists)
Configure env vars on the machine that runs `apps/web` / worker (Coolify or local):
- `ANTHROPIC_API_KEY` — planning roles (or use NIM/Gemini free tier per ADR references)
- `GOOGLE_GENERATIVE_AI_API_KEY`, `NVIDIA_API_KEY` — optional free-tier planning/gates
Until Settings UI ships, `OPENAI_BASE_URL` + `OPENAI_API_KEY` still power the existing chat route.
### D. Agent-pipeline convoy (when you start building)
- [ ] Run **role-conductor** (Cursor): convoy for model registry per kickoff ritual above → `.convoys/model-routing-registry.md`
- [ ] **role-architect** → briefs map to Epic 1 tasks
- [ ] Epic 2 (optional parallel): install `.convoys/` scaffolding + `log-convoy-event.sh`
### E. First implementation task
Start with **`Task-shared-schemas-resolve-model`** — no DB migration, pure `packages/shared` + tests. Then migration, tRPC, UI.
### F. Not needed yet
- Forgejo / connected repos (Epic 3)
- Orchestrator or worker (Epics 6–7)
- LiteLLM (optional gateway later)
- Symphony
## Objectives and success criteria
- Workspace admins can CRUD model provider aliases (Anthropic, OpenAI, Google, OpenAI-compatible for Ollama/NIM/LiteLLM).
- Workspace admins can map any `role_id` (nine L2 defaults + custom) to a provider alias.
- `resolveModel()` implements ADR resolution order (brief → convoy → workspace → template).
- Settings UI: Providers + Role routing pages with connection test.
- `agent_runs` records `model_alias`, `provider_type`, `model_id`, `model_tier` on completion.
- Documented in ADR; `.env.example` updated with referenced env var names.
## Scope and boundaries
**In scope (full plan)**
- Nine epics — see [Epic roadmap](#epic-roadmap-complete) below.
- Epic 1 (model registry) is **ready** with six tasks; epics 2–9 are **draft** stubs for architect decomposition.
- ADR at `docs/adr-model-routing.md`.
**Out of scope (separate future plans)**
- Agent-pipeline Phase 2b deliverable templates.
- Agent-pipeline Phase 4 drift/rollup workers (replace GHA cron).
- LiteLLM deployment guide (operators use OpenAI-compatible alias).
- Symphony adoption.
## Cross-team collaborators
| Name / group | Role |
|--------------|------|
| Solo operator | Product, implement, dogfood pipeline |
## Epic roadmap (complete)
Sequential order with parallel opportunities noted. Only **Epic 1** has bead-scale tasks today; epics 2–9 are draft stubs ready for architect decomposition when the prior epic lands.
```mermaid
flowchart LR
E1[1 Model registry]
E2[2 Convoy scaffolding]
E3[3 Connected repos]
E4[4 Convoy lifecycle MCP]
E5[5 Gates UI]
E6[6 Orchestrator]
E7[7 Worker v1]
E8[8 Forge webhooks]
E9[9 Fleet dashboard]
E1 --> E6
E1 --> E7
E2 --> E4
E3 --> E6
E4 --> E5
E4 --> E6
E5 --> E6
E6 --> E7
E7 --> E8
E5 --> E9
E4 --> E9
```
| # | Epic | Status | Delivers | Depends on |
|---|------|--------|----------|------------|
| 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — |
| 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | draft | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) |
| 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | draft | `repo_links` table, “paste remote URL”, forge adapter interface | — |
| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `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 |
| 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 |
| 9 | [Fleet dashboard](./Epic-fleet-dashboard/Epic-fleet-dashboard.md) | draft | Cross-repo home, `convoys-*` workspaces, “needs you” rollup | 4, 5 |
### Overlap with existing plans
| Existing artifact | How this roadmap relates |
|-------------------|-------------------------|
| [Plan-agent-coordination](../Plan-agent-coordination/Plan-agent-coordination.md) | Epics 1 (logging) and 4–7 **extend** it; `claim_task` / `complete_task` stay canonical |
| [Epic-optional-orchestrator](../Plan-agent-coordination/Epic-optional-orchestrator/Epic-optional-orchestrator.md) | **Superseded in intent** by Epic 6 here — richer spec, Echodo-native, no Symphony dependency |
| Agent-pipeline v0.4 Phase 2a | Epic 4 implements the MCP tools described there |
| Agent-pipeline v0.4 Phase 4 workers | Out of scope until Epic 8+; drift/rollup is a future Plan, not this roadmap |
### End-state (what “done” looks like for the full plan)
Operator opens Echodo fleet view → starts convoy chat → hits **Go** → orchestrator runs pipeline roles with per-role models → worker executes briefs against connected repos → gates surface in inbox with AI co-pilot → approve → repeat until `released`.
## Epics (links)
| Epic | Link |
|------|------|
| 1 Model provider registry | `./Epic-model-provider-registry/Epic-model-provider-registry.md` |
| 2 Pipeline convoy scaffolding | `./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md` |
| 3 Connected repos | `./Epic-connected-repos/Epic-connected-repos.md` |
| 4 Convoy lifecycle MCP | `./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md` |
| 5 Mission control gates UI | `./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md` |
| 6 Orchestrator | `./Epic-apps-orchestrator/Epic-apps-orchestrator.md` |
| 7 Worker v1 | `./Epic-apps-worker-v1/Epic-apps-worker-v1.md` |
| 8 Forge webhooks | `./Epic-forge-webhooks/Epic-forge-webhooks.md` |
| 9 Fleet dashboard | `./Epic-fleet-dashboard/Epic-fleet-dashboard.md` |
## Metrics and milestones
| Milestone | Epic | Metric |
|-----------|------|--------|
| M0 ADR | — | `docs/adr-model-routing.md` merged |
| M1 Model registry | 1 | Settings: configure + test Anthropic + Ollama aliases |
| M2 Dogfood loop | 1–2 | One task via claim_task → complete_task with model fields |
| M3 Repos connected | 3 | Paste Forge/GitHub URL; clone succeeds from Echodo |
| M4 Convoy state | 4 | `transition_convoy_status` enforces gate actors |
| M5 Human surface | 5 | Gate inbox shows pending `convoy_review` |
| M6 Automation | 6–7 | Orchestrator dispatches one implementer brief unattended |
| M7 PR loop | 8 | Merge webhook moves convoy to `merged` |
| M8 Fleet | 9 | Single view across ≥2 connected repos |
## Risks and mitigations
| Risk | Mitigation |
|------|------------|
| Mac Ollama unreachable from Coolify-hosted web | `testProvider` surfaces error; document LAN URL; dev can run web locally |
| Cursor slug drift in old convoys | Legacy alias map in ADR; conductor uses registry aliases for new convoys |
| Scope creep into orchestrator | ADR + plan explicitly defer daemon/worker |
## Links
- [ADR: Model routing](../../docs/adr-model-routing.md)
- [Plan: Agent coordination](../Plan-agent-coordination/Plan-agent-coordination.md)
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`

View file

@ -335,6 +335,9 @@ importers:
'@ai-sdk/anthropic':
specifier: ^1.0.0
version: 1.2.12(zod@3.25.76)
'@ai-sdk/google':
specifier: ^1.0.0
version: 1.2.22(zod@3.25.76)
'@ai-sdk/openai':
specifier: ^1.0.0
version: 1.3.24(zod@3.25.76)
@ -354,6 +357,9 @@ importers:
packages/database:
dependencies:
'@tasks/shared':
specifier: workspace:*
version: link:../shared
drizzle-orm:
specifier: ^0.38.0
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
@ -401,6 +407,12 @@ packages:
peerDependencies:
zod: ^3.0.0
'@ai-sdk/google@1.2.22':
resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/openai@1.3.24':
resolution: {integrity: sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==}
engines: {node: '>=18'}
@ -5391,6 +5403,12 @@ snapshots:
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/google@1.2.22(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai@1.3.24(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 1.1.3