feat(ai-router): test an endpoint before saving it, and re-point a live lane

Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.

**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.

The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.

Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.

**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.

Adds `Lane.router_profile` to the client type (the payload already carried it).
This commit is contained in:
2026-09-16 11:26:00 +07:00
parent 9128352d1b
commit 00a2aedb09
19 changed files with 563 additions and 10 deletions
+91
View File
@@ -7,6 +7,8 @@
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
* NOT repeated here — this card only ever renders inside the Workspace
* lane-detail section, whose header above it already shows them. The repo
* card also carries the lane's AI Router picker — the Add-lane modal is the
* only other place one is chosen, and a lane outlives its creation. The repo
* tooling dropdown (`LaneToolsMenu`, exported from this file) and the
* remove-lane button live in Workspace's page header / pipeline-picker row
* instead of here, so they stay reachable and un-clipped regardless of this
@@ -20,6 +22,7 @@ import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
import type { AiRouter } from "../../lib/api";
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
@@ -168,6 +171,92 @@ function useLaneIntegrations(
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
}
/**
* Which AI Router this lane runs against, and the one place it can be changed
* after the lane exists — the Add-lane modal is the only other one, and a lane
* outlives the moment it was created.
*
* Applying writes the lane's own config on disk as well as the DB column, so it
* goes through `POST /lanes/:id/router/apply` and never a PATCH. A failure puts
* the select back: the row must not claim a router the lane did not get.
*
* Rendered only when there is something to choose. A user who has never opened
* the AI Router tab has exactly the built-in `native` profile, and a select
* with one option is a control that cannot do anything.
*/
function LaneRouterRow({ lane }: { lane: Lane }) {
const { t } = useTranslation(["lanes"]);
const [routers, setRouters] = useState<AiRouter[] | null>(null);
const [current, setCurrent] = useState(lane.router_profile || "native");
const [busy, setBusy] = useState(false);
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
// The polled lane list is the source of truth; a switch elsewhere (the lane
// was reset to native by a profile delete, say) has to land here too.
useEffect(() => {
setCurrent(lane.router_profile || "native");
setNote(null);
}, [lane.id, lane.router_profile]);
useEffect(() => {
let alive = true;
api.aiRouters
.list()
.then((r) => {
if (alive) setRouters(r.items);
})
.catch(() => {
// Silent, same contract as the git/runtime pollers above: a card that
// cannot list routers simply shows no router row.
if (alive) setRouters([]);
});
return () => {
alive = false;
};
}, []);
if (!routers || routers.length <= 1) return null;
const change = async (name: string) => {
const previous = current;
setCurrent(name);
setBusy(true);
setNote(null);
try {
await api.lanes.routerApply(lane.id, name);
setNote({ ok: true, text: t("router.applied") });
} catch (err) {
setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(false);
}
};
return (
<div className="mb-2 text-[11px]" data-testid={`lane-router-${lane.id}`}>
<label className="flex items-center gap-1.5 text-fg-muted">
{t("router.label")}
<select
value={current}
disabled={busy}
onChange={(e) => void change(e.target.value)}
className="flex-1 truncate rounded border border-border-light bg-surface-2 px-1.5 py-0.5 text-fg-secondary disabled:opacity-50"
>
{routers.map((r) => (
<option key={r.name} value={r.name}>
{r.label}
</option>
))}
</select>
</label>
{note && (
<p className={`mt-1 ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
)}
</div>
);
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
idle: "bg-surface-4",
@@ -502,6 +591,8 @@ export default function LaneCard({
</div>
</dl>
<LaneRouterRow lane={lane} />
{childWorktrees && childWorktrees.length > 0 && (
<div
data-testid="lane-child-worktrees"