Submatter/apps/web/app/(app)/[workspaceSlug]/settings/harnesses/page.tsx
Randall Stillwell 6ccb060bea feat(second-brain): open self-hosted Obsidian for crews — harness-agnostic v0.1
- MCP create-backlog-item for natural language Plan/Epic/Task authoring
- Worker non-code: optional worktree + per-harness allowlist + doc_review→published
- Harness registry: harness_providers/role_harness_routes, WorkerAdapter (minimal|crewai|langgraph|remote-ag-ui), AG-UI POST /api/ag-ui, Settings → Harnesses with Test
- Plans tree: backlog.listTree + createPlan/Epic/Task + Start→Convoy, web CRUD without CLI
- Fleet: /fleet rollup across workspaces + SSE /api/fleet/events

Decision: docs/decisions/2026-08-25-second-brain-os-pivot.md
Convoy: .convoys/second-brain-os-v01

Co-authored-by: orchestrator
2026-08-25 16:20:53 -05:00

165 lines
13 KiB
TypeScript

"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Loader2, Plus, Wrench, X } from "lucide-react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import type { HarnessType } from "@tasks/shared/worker";
const HARNESS_TYPES: HarnessType[] = ["minimal", "crewai", "langgraph", "remote-ag-ui"];
type HarnessForm = {
alias: string;
displayName: string;
harnessType: HarnessType;
endpointUrl: string;
authEnvVar: string;
enabled: boolean;
};
const EMPTY_FORM: HarnessForm = {
alias: "",
displayName: "",
harnessType: "minimal",
endpointUrl: "",
authEnvVar: "",
enabled: true,
};
export default function HarnessesSettingsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug as string | undefined;
const utils = api.useUtils();
const listQuery = api.harnesses.list.useQuery({ workspace: workspaceSlug ?? "" }, { enabled: Boolean(workspaceSlug) });
const routesQuery = api.harnesses.listRoutes.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<HarnessForm>(EMPTY_FORM);
const [testResult, setTestResult] = React.useState<Record<string, string>>({});
const [newRoleId, setNewRoleId] = React.useState("");
const [newHarnessId, setNewHarnessId] = React.useState("");
const createMut = api.harnesses.create.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
const updateMut = api.harnesses.update.useMutation({ onSuccess: () => { void utils.harnesses.list.invalidate(); setDialogOpen(false); } });
const deleteMut = api.harnesses.delete.useMutation({ onSuccess: () => void utils.harnesses.list.invalidate() });
const testMut = api.harnesses.testHarness.useMutation({
onSuccess: (result, vars) => {
setTestResult((prev) => ({ ...prev, [vars.harnessId]: result.ok ? `OK in ${result.latencyMs}ms` : `Failed: ${result.error ?? "unknown"}` }));
},
});
const upsertRouteMut = api.harnesses.upsertRoute.useMutation({ onSuccess: () => { void utils.harnesses.listRoutes.invalidate(); setNewRoleId(""); setNewHarnessId(""); } });
const deleteRouteMut = api.harnesses.deleteRoute.useMutation({ onSuccess: () => void utils.harnesses.listRoutes.invalidate() });
const canManage = listQuery.data?.canManage ?? false;
const harnesses = listQuery.data?.harnesses ?? [];
const routes = routesQuery.data?.routes ?? [];
const openCreate = () => { setEditingId(null); setForm(EMPTY_FORM); setDialogOpen(true); };
const openEdit = (h: (typeof harnesses)[number]) => {
setEditingId(h.id);
setForm({ alias: h.alias, displayName: h.displayName, harnessType: h.harnessType as HarnessType, endpointUrl: h.endpointUrl ?? "", authEnvVar: h.authEnvVar ?? "", enabled: h.enabled });
setDialogOpen(true);
};
const submit = () => {
if (!workspaceSlug) return;
const payload = { workspace: workspaceSlug, alias: form.alias, displayName: form.displayName, harnessType: form.harnessType, endpointUrl: form.endpointUrl.trim() || null, authEnvVar: form.authEnvVar.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"><Wrench className="size-5 text-primary" /></div>
<div>
<h1 className="text-xl font-semibold">Harnesses</h1>
<p className="text-xs text-muted-foreground">Providers for <code className="text-[10px]">minimal|crewai|langgraph|remote-ag-ui</code> + role routing. Auth tokens stay in server env.</p>
</div>
</div>
{canManage ? <Button size="sm" className="gap-1.5" onClick={openCreate}><Plus className="size-4" />Add harness</Button> : null}
</div>
{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>
: harnesses.length === 0 ? <div className="rounded-lg border border-dashed px-6 py-12 text-center text-sm text-muted-foreground">No harnesses yet. Add minimal or a remote AG-UI endpoint.</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">Endpoint</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">
{harnesses.map((h) => (
<tr key={h.id} className="hover:bg-muted/20">
<td className="px-4 py-2"><div className="font-medium">{h.alias}</div><div className="text-xs text-muted-foreground">{h.displayName}</div></td>
<td className="px-4 py-2 font-mono text-xs">{h.harnessType}</td>
<td className="max-w-[24ch] truncate px-4 py-2 font-mono text-xs text-muted-foreground">{h.endpointUrl ?? "—"}</td>
<td className="px-4 py-2 text-xs">{h.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, harnessId: h.id })}>Test</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(h)}>Edit</Button>
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Delete harness "${h.alias}"?`)) deleteMut.mutate({ workspace: workspaceSlug, id: h.id }); }}>Delete</Button>
{testResult[h.id] ? <div className="mt-1 text-[10px] text-muted-foreground">{testResult[h.id]}</div> : null}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-10 rounded-lg border">
<div className="border-b px-4 py-3 text-sm font-medium">Role → harness routing</div>
{routesQuery.isLoading ? <div className="flex justify-center py-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>
: (
<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">Harness</th><th className="px-4 py-2 text-left font-medium">Type</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 ? 4 : 3} className="px-4 py-8 text-center text-muted-foreground">No harness routes. Map role-implementer to crewai etc.</td></tr>
: routes.map((r) => (
<tr key={r.id} className="hover:bg-muted/20">
<td className="px-4 py-2 font-mono text-xs">{r.roleId}</td>
<td className="px-4 py-2 font-mono text-xs">{r.harnessAlias}</td>
<td className="px-4 py-2 font-mono text-xs">{r.harnessType}</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={r.harnessProviderId} onChange={(e) => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: r.roleId, harnessProviderId: e.target.value })}>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select><Button variant="ghost" size="sm" className="text-destructive" onClick={() => { if (window.confirm(`Remove harness route for ${r.roleId}?`)) deleteRouteMut.mutate({ workspace: workspaceSlug!, id: r.id }); }}>Remove</Button></td> : null}
</tr>
))}
</tbody>
</table>
)}
{canManage && harnesses.length > 0 ? (
<div className="flex flex-wrap items-end gap-3 border-t bg-muted/20 p-4">
<div><label className="mb-1 block text-xs text-muted-foreground">role_id</label><Input className="w-48 font-mono text-xs" placeholder="role-implementer" value={newRoleId} onChange={(e) => setNewRoleId(e.target.value)} /></div>
<div><label className="mb-1 block text-xs text-muted-foreground">Harness</label><select className="h-9 rounded-md border border-input bg-background px-3 text-sm" value={newHarnessId} onChange={(e) => setNewHarnessId(e.target.value)}><option value="">Select…</option>{harnesses.filter((h) => h.enabled).map((h) => <option key={h.id} value={h.id}>{h.alias}</option>)}</select></div>
<Button size="sm" className="gap-1" disabled={!newRoleId.trim() || !newHarnessId || upsertRouteMut.isPending} onClick={() => upsertRouteMut.mutate({ workspace: workspaceSlug!, roleId: newRoleId.trim(), harnessProviderId: newHarnessId })}><Plus className="size-3.5" />Map</Button>
</div>
) : null}
</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 harness" : "Add harness"}</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="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="Harness type"><select className="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm" value={form.harnessType} onChange={(e) => setForm((f) => ({ ...f, harnessType: e.target.value as HarnessType }))}>{HARNESS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}</select></Field>
<Field label="Endpoint URL (nullable)"><Input placeholder="https://crew.example.com/ag-ui" value={form.endpointUrl} onChange={(e) => setForm((f) => ({ ...f, endpointUrl: e.target.value }))} /></Field>
<Field label="Auth env var (write-only)"><Input placeholder="HARNESS_AUTH_TOKEN" value={form.authEnvVar} onChange={(e) => setForm((f) => ({ ...f, authEnvVar: e.target.value }))} /><p className="mt-1 text-[10px] text-muted-foreground">Token lives in server env, never returned to browser.</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>; }