fix(dashboard): carry conductor requirements and focus the repeated task

The drawer's "Repeat" for a Conductor task dropped the runner/model pinning and
left the operator staring at the finished run:

- `hubTaskSchema` now parses the hub's `requirements` (`.catch(null)` so an odd
  shape never fails the whole task parse), and `ConductorTaskDetail` exposes
  `cli`/`model` (`null` when the hub sends none).
- `repeatReqForConductor` carries `cli`/`model` when present and OMITS them
  otherwise — the route's Zod takes both as optional strings, so a `null` would
  400. The two fields are independent.
- `performAction` reads the response body once and returns it, so the repeat can
  report the CANVAS id of the created task (`task_id` / `data.id` /
  `result.task.id`, each with its node prefix). `OrchestrationPageClient` then
  refetches and focuses it via `?node=`; History keeps its current behavior.
- `conductor-routes-auth.test.ts` covers the creation route through its `ROUTES`
  array; the duplicated source assertion left `conductor-create-route.test.ts`.

Refs #12639
This commit is contained in:
diegosouzapw
2026-09-07 16:28:56 -03:00
parent 3ef0aef15b
commit a7d430fe9e
7 changed files with 335 additions and 53 deletions

View File

@@ -133,6 +133,13 @@ export default function OrchestrationPageClient() {
[collapsed, setParams]
);
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
const onActionDone = useCallback(
(newNodeId?: string) => {
refetch();
if (newNodeId) setParams({ node: newNodeId });
},
[refetch, setParams]
);
const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null;
const onNodeClick = (id: string) =>
@@ -188,8 +195,17 @@ export default function OrchestrationPageClient() {
{tab === "history" && <HistoryTab />}
</div>
</div>
{/* A successful repeat hands back the CANVAS id of the task it created: refetch, then
focus it (`?node=`) so the operator lands on the new run instead of staring at the
finished one. No id (approve/cancel, or a creation response without one) keeps the
current selection. The History tab renders its own drawer and deliberately does NOT
navigate (HistoryTab.tsx) — its runs are not addressable in the live snapshot. */}
{tab !== "history" && (
<OrchestrationDrawer node={selectedNode} onClose={closeDrawer} onActionDone={refetch} />
<OrchestrationDrawer
node={selectedNode}
onClose={closeDrawer}
onActionDone={onActionDone}
/>
)}
</div>
);

View File

@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
import { StatusDot } from "@/shared/components/flow/StatusDot";
import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes";
import { useDrawerDetail } from "./useDrawerDetail";
import type { DrawerError } from "./useDrawerDetail";
import type { DrawerError, RepeatOutcome } from "./useDrawerDetail";
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
import type { A2ATask } from "@/lib/a2a/taskManager";
@@ -349,15 +349,18 @@ function RepeatButton({
}: {
canRepeat: boolean;
busy: boolean;
repeat: () => Promise<boolean>;
onActionDone: () => void;
repeat: () => Promise<RepeatOutcome>;
onActionDone: (newNodeId?: string) => void;
onToast: (text: string) => void;
t: Translate;
}) {
const { confirming, onClick } = useTwoClickConfirm(() => {
void (async () => {
if (await repeat()) {
onActionDone();
// The created task's CANVAS id is handed to `onActionDone` so the page can focus it —
// `undefined` when the creation response carried no usable id (plain refetch, no jump).
const { ok, newNodeId } = await repeat();
if (ok) {
onActionDone(newNodeId ?? undefined);
onToast(t("repeatDone"));
}
})();
@@ -395,8 +398,8 @@ function DrawerActions({
busy: boolean;
approve: () => Promise<boolean>;
cancel: () => Promise<boolean>;
repeat: () => Promise<boolean>;
onActionDone: () => void;
repeat: () => Promise<RepeatOutcome>;
onActionDone: (newNodeId?: string) => void;
onToast: (text: string) => void;
t: Translate;
}) {
@@ -488,7 +491,7 @@ export function OrchestrationDrawer({
}: {
node: OrchNode | null;
onClose: () => void;
onActionDone: () => void;
onActionDone: (newNodeId?: string) => void;
}) {
const t = useTranslations("orchestration");
const {

View File

@@ -143,7 +143,14 @@ function repeatReqForA2a(
};
}
/** conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). */
/**
* conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route).
* `cli`/`model` come from the hub's `requirements` (`ConductorTaskDetail`, hubProxy.ts) and are
* carried over so the repeat lands on the SAME runner profile/model the original task was
* pinned to. Both are `z.string().optional()` in the route's Zod: a `null` would 400, so a
* missing requirement must OMIT the field (`undefined`) rather than send `null` — and the two
* are independent (one may be set while the other is not).
*/
function repeatReqForConductor(detail: unknown): { url: string; init: RequestInit } | null {
const d = detail as ConductorTaskDetail | null;
if (!d?.repo || !d?.prompt) return null;
@@ -154,6 +161,8 @@ function repeatReqForConductor(detail: unknown): { url: string; init: RequestIni
prompt: d.prompt,
baseRef: d.base_ref ?? undefined,
mode: d.mode,
cli: d.cli ?? undefined,
model: d.model ?? undefined,
}),
};
}
@@ -168,6 +177,36 @@ export function repeatReqFor(
return null;
}
/** `<prefix><id>` when `id` is a non-empty string, `null` otherwise (never a bare prefix). */
function prefixedNodeId(prefix: string, id: unknown): string | null {
return typeof id === "string" && id.length > 0 ? `${prefix}${id}` : null;
}
/**
* CANVAS node id of the task a successful repeat just created, from the creation response
* body — `null` whenever the body does not carry a usable id (the caller then simply refetches
* without focusing anything). The canvas addresses nodes by PREFIXED id
* (`mergeSnapshot.ts`), so the raw upstream id is never returned on its own. Response
* envelopes, verified against the live routes:
* - conductor (`POST /api/conductor/tasks`): `{ task_id }`.
* - cloud-agent (`POST /api/v1/agents/tasks`): `{ data: { id } }`.
* - a2a (`POST /a2a`, JSON-RPC `message/send`): `{ result: { task: { id } } }`.
*/
export function newNodeIdFrom(node: OrchNode, body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
if (node.id.startsWith("conductor:task:")) return prefixedNodeId("conductor:task:", b.task_id);
if (node.id.startsWith("cloud-agent:")) {
const data = b.data as { id?: unknown } | undefined;
return prefixedNodeId("cloud-agent:", data?.id);
}
if (node.id.startsWith("a2a:")) {
const result = b.result as { task?: { id?: unknown } } | undefined;
return prefixedNodeId("a2a:", result?.task?.id);
}
return null;
}
/**
* Unwraps a task-detail GET response to the actual task payload. Each source's
* route has its own envelope — verified against the live handlers, not assumed:
@@ -258,38 +297,57 @@ function useFetchDetail(
* that never happened, so the `/a2a` action also inspects the envelope. Only the numeric
* `error.code` is surfaced (`RPC <code>`) — never the upstream `error.message`.
*/
async function jsonRpcErrorCode(res: {
json?: () => Promise<unknown>;
}): Promise<number | undefined> {
function jsonRpcErrorCode(body: unknown): number | undefined {
const b = body as { error?: { code?: unknown } } | undefined | null;
const code = b?.error?.code;
return typeof code === "number" ? code : b?.error ? -32603 : undefined;
}
/**
* Reads an action response body ONCE, tolerating a non-JSON/empty body. A body that cannot be
* parsed is not evidence of failure — the status already stood — so it yields `null` and the
* action stays successful (it just has no new-task id to focus).
*/
async function readJsonBody(res: { json?: () => Promise<unknown> }): Promise<unknown> {
try {
const body = (await res.json?.()) as { error?: { code?: unknown } } | undefined;
const code = body?.error?.code;
return typeof code === "number" ? code : body?.error ? -32603 : undefined;
return (await res.json?.()) ?? null;
} catch {
// A non-JSON / already-consumed body is not evidence of failure — the status stands.
return undefined;
return null;
}
}
/** Outcome of an action POST: whether it succeeded, plus the parsed body on success. */
interface ActionOutcome {
ok: boolean;
body: unknown;
}
async function performAction(
req: { url: string; init: RequestInit } | null,
setActionError: (text: string) => void
): Promise<boolean> {
if (!req) return false;
): Promise<ActionOutcome> {
if (!req) return { ok: false, body: null };
try {
const res = await fetch(req.url, req.init);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await readJsonBody(res);
if (req.url === "/a2a") {
const code = await jsonRpcErrorCode(res);
const code = jsonRpcErrorCode(body);
if (code !== undefined) throw new Error(`RPC ${code}`);
}
return true;
return { ok: true, body };
} catch (err) {
setActionError(toSafeErrorText(err));
return false;
return { ok: false, body: null };
}
}
/** Result of the drawer's repeat action: success plus the canvas id of the created task. */
export interface RepeatOutcome {
ok: boolean;
newNodeId: string | null;
}
export function useDrawerDetail(node: OrchNode | null) {
const [detail, setDetail] = useState<unknown | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -306,8 +364,10 @@ export function useDrawerDetail(node: OrchNode | null) {
const { canApprove, canCancel } = deriveActionAvailability(route, node);
const repeatReq = node ? repeatReqFor(node, detail) : null;
const runAction = async (req: { url: string; init: RequestInit } | null): Promise<boolean> => {
if (busy) return false;
const runAction = async (
req: { url: string; init: RequestInit } | null
): Promise<ActionOutcome> => {
if (busy) return { ok: false, body: null };
setBusy(true);
try {
return await performAction(req, setActionError);
@@ -315,6 +375,8 @@ export function useDrawerDetail(node: OrchNode | null) {
setBusy(false);
}
};
const runBooleanAction = async (req: { url: string; init: RequestInit } | null) =>
(await runAction(req)).ok;
return {
detail,
@@ -325,8 +387,13 @@ export function useDrawerDetail(node: OrchNode | null) {
canApprove,
canCancel,
canRepeat: !!repeatReq && !busy,
approve: () => runAction(route?.approveReq ?? null),
cancel: () => runAction(route?.cancelReq ?? null),
repeat: () => runAction(repeatReq),
approve: () => runBooleanAction(route?.approveReq ?? null),
cancel: () => runBooleanAction(route?.cancelReq ?? null),
// Only the repeat reports a new node id: approve/cancel act on the task already open, so
// there is nothing new to focus (and their responses can echo the SAME task's id back).
repeat: async (): Promise<RepeatOutcome> => {
const { ok, body } = await runAction(repeatReq);
return { ok, newNodeId: ok && node ? newNodeIdFrom(node, body) : null };
},
};
}

View File

@@ -45,6 +45,10 @@ export interface ConductorTaskDetail extends FleetTask {
tests: unknown;
council: unknown;
created_at: string | null;
/** Runner profile the task was pinned to (hub `requirements.cli`), `null` when unconstrained. */
cli: string | null;
/** Model the task was pinned to (hub `requirements.model`), `null` when unconstrained. */
model: string | null;
}
// ============ Untrusted hub shapes (parse only what we read) ============
@@ -74,6 +78,13 @@ const hubTaskSchema = z.object({
tests: z.unknown().optional(),
})
.nullish(),
// The hub echoes back the `requirements` object `createConductorTask` sends on creation.
// `.catch(null)` keeps an unexpected shape from failing the WHOLE task parse — a single odd
// requirement must not blank the fleet snapshot (the list parses with this same schema).
requirements: z
.object({ cli: z.string().nullish(), model: z.string().nullish() })
.nullish()
.catch(null),
council: z.unknown().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
@@ -159,13 +170,16 @@ export async function getFleetSnapshot(opts: HubProxyOptions = {}): Promise<Flee
hubGet("/v1/tasks", opts),
]);
if (rawRunners === null || rawTasks === null) return { offline: true, runners: [], tasks: [] };
const runners = z.array(hubRunnerSchema).parse(rawRunners).map((r) => ({
id: r.id,
name: r.capabilities.name ?? "?",
clis: (r.capabilities.clis ?? []).map((c) => c.profile),
online: r.online !== false,
draining: r.draining === true,
}));
const runners = z
.array(hubRunnerSchema)
.parse(rawRunners)
.map((r) => ({
id: r.id,
name: r.capabilities.name ?? "?",
clis: (r.capabilities.clis ?? []).map((c) => c.profile),
online: r.online !== false,
draining: r.draining === true,
}));
const tasks = z.array(hubTaskSchema).parse(rawTasks).map(toFleetTask);
emitFleetTransitions(tasks);
return { offline: false, runners, tasks };
@@ -190,6 +204,8 @@ export async function getConductorTaskDetail(
tests: t.manifest?.tests ?? null,
council: t.council ?? null,
created_at: t.created_at ?? null,
cli: t.requirements?.cli ?? null,
model: t.requirements?.model ?? null,
};
} catch {
return null;

View File

@@ -57,21 +57,8 @@ test.after(async () => {
}
});
test("route: requireManagementAuth antes de criar a task no hub", () => {
const src = fs.readFileSync(
path.join(process.cwd(), "src/app/api/conductor/tasks/route.ts"),
"utf8"
);
const authAt = src.indexOf("requireManagementAuth(");
assert.ok(authAt > 0, "handler chama requireManagementAuth");
assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
const proxyAt = src.indexOf("createConductorTask(");
assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
assert.ok(
!src.includes("CONDUCTOR_HUB_TOKEN"),
"token nunca manuseado na rota (vive no hubProxy)"
);
});
// A prova de fonte "requireManagementAuth antes do proxy ao hub" desta rota vive no array
// `ROUTES` de `conductor-routes-auth.test.ts` (uma única implementação para as quatro rotas).
test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => {
process.env.CONDUCTOR_HUB_URL = await fakeHub({

View File

@@ -8,6 +8,9 @@ const ROUTES = [
"src/app/api/conductor/fleet/route.ts",
"src/app/api/conductor/tasks/[id]/route.ts",
"src/app/api/conductor/tasks/[id]/cancel/route.ts",
// Rota de criação (repeat do drawer): coberta aqui pelo mesmo padrão, em vez de
// duplicar o teste de fonte dentro de `conductor-create-route.test.ts`.
"src/app/api/conductor/tasks/route.ts",
];
for (const route of ROUTES) {
@@ -16,8 +19,13 @@ for (const route of ROUTES) {
const authAt = src.indexOf("requireManagementAuth(");
assert.ok(authAt > 0, "handler chama requireManagementAuth");
assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
const proxyAt = src.search(/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(/);
const proxyAt = src.search(
/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(|createConductorTask\(/
);
assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
assert.ok(!src.includes("CONDUCTOR_HUB_TOKEN"), "token nunca manuseado na rota (vive no hubProxy)");
assert.ok(
!src.includes("CONDUCTOR_HUB_TOKEN"),
"token nunca manuseado na rota (vive no hubProxy)"
);
});
}

View File

@@ -9,7 +9,10 @@ vi.mock("next-intl", () => ({
}));
import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer";
import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
import {
newNodeIdFrom,
repeatReqFor,
} from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
function render(el: React.ReactElement) {
const c = document.createElement("div");
@@ -784,3 +787,185 @@ describe("OrchestrationDrawer repeat action (two-click confirm)", () => {
cleanup();
});
});
describe("repeatReqFor conductor requirements (cli/model)", () => {
const node = {
id: "conductor:task:1",
kind: "work",
source: "conductor",
state: "succeeded",
label: "x",
};
it("carries cli/model when the hub detail exposes them (a repeat must land on the same runner profile/model)", () => {
const detail = {
repo: "https://github.com/x/y",
prompt: "fix the bug",
base_ref: "main",
mode: "auto",
cli: "claude",
model: "sonnet",
};
const req = repeatReqFor(node as never, detail);
expect(JSON.parse(String(req?.init.body))).toEqual({
repoUrl: "https://github.com/x/y",
prompt: "fix the bug",
baseRef: "main",
mode: "auto",
cli: "claude",
model: "sonnet",
});
});
it("omits cli/model entirely when the detail has them null (the route's Zod treats both as optional strings — `null` would 400)", () => {
const detail = {
repo: "https://github.com/x/y",
prompt: "fix the bug",
base_ref: "main",
mode: "auto",
cli: null,
model: null,
};
const body = String(repeatReqFor(node as never, detail)?.init.body);
expect(JSON.parse(body)).toEqual({
repoUrl: "https://github.com/x/y",
prompt: "fix the bug",
baseRef: "main",
mode: "auto",
});
expect(body).not.toContain("cli");
expect(body).not.toContain("model");
});
it("carries only the field that is present (cli set, model null — they are independent)", () => {
const detail = {
repo: "https://github.com/x/y",
prompt: "fix the bug",
base_ref: null,
mode: "solo",
cli: "codex",
model: null,
};
expect(JSON.parse(String(repeatReqFor(node as never, detail)?.init.body))).toEqual({
repoUrl: "https://github.com/x/y",
prompt: "fix the bug",
mode: "solo",
cli: "codex",
});
});
});
describe("newNodeIdFrom", () => {
it("maps each source's creation response to the CANVAS node id (prefixed), never the raw id", () => {
const cond = { id: "conductor:task:1", source: "conductor" };
expect(newNodeIdFrom(cond as never, { task_id: "t2" })).toBe("conductor:task:t2");
const ca = { id: "cloud-agent:1", source: "cloud-agent" };
expect(newNodeIdFrom(ca as never, { data: { id: "c2" } })).toBe("cloud-agent:c2");
const a2a = { id: "a2a:1", source: "a2a" };
expect(newNodeIdFrom(a2a as never, { result: { task: { id: "n2" } } })).toBe("a2a:n2");
});
it("returns null for a body that does not carry a usable id (never throws, never yields a bare prefix)", () => {
const cond = { id: "conductor:task:1", source: "conductor" };
expect(newNodeIdFrom(cond as never, null)).toBeNull();
expect(newNodeIdFrom(cond as never, {})).toBeNull();
expect(newNodeIdFrom(cond as never, { task_id: "" })).toBeNull();
expect(newNodeIdFrom(cond as never, { task_id: 7 })).toBeNull();
expect(newNodeIdFrom(cond as never, "nope")).toBeNull();
const ca = { id: "cloud-agent:1", source: "cloud-agent" };
expect(newNodeIdFrom(ca as never, { data: {} })).toBeNull();
const a2a = { id: "a2a:1", source: "a2a" };
expect(newNodeIdFrom(a2a as never, { result: {} })).toBeNull();
});
it("returns null for a node whose source has no repeat contract", () => {
const overflow = { id: "overflow:1", kind: "overflow" };
expect(newNodeIdFrom(overflow as never, { task_id: "t2" })).toBeNull();
});
});
describe("OrchestrationDrawer repeat focuses the newly created task", () => {
const CONDUCTOR_DETAIL = {
id: "1",
status: "succeeded",
mode: "solo",
repo: "https://github.com/x/y",
runner: null,
summary: null,
branch: null,
error: null,
updated_at: null,
prompt: "fix the bug",
base_ref: "main",
tests: null,
council: null,
created_at: null,
cli: "claude",
model: "sonnet",
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
function findRepeatButton(c: HTMLElement): HTMLButtonElement {
return Array.from(c.querySelectorAll("button")).find(
(b) => b.textContent?.includes("actionRepeat") || b.textContent?.includes("repeatConfirm")
) as HTMLButtonElement;
}
async function repeatOnce(onActionDone: (id?: string) => void) {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
return Promise.resolve({
ok: true,
status: 201,
json: () => Promise.resolve({ task_id: "t_new" }),
});
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(CONDUCTOR_DETAIL) });
});
vi.stubGlobal("fetch", fetchMock);
const node = {
id: "conductor:task:1",
kind: "work",
source: "conductor",
state: "running",
label: "x",
};
const { c, cleanup } = render(
<OrchestrationDrawer node={node as never} onClose={() => {}} onActionDone={onActionDone} />
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
findRepeatButton(c).click();
});
await act(async () => {
findRepeatButton(c).click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
return { c, cleanup, fetchMock };
}
it("hands the canvas id of the created task to onActionDone, so the page can focus it", async () => {
const seen: Array<string | undefined> = [];
const { c, cleanup, fetchMock } = await repeatOnce((id) => seen.push(id));
const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST");
expect(post![0]).toBe("/api/conductor/tasks");
expect(JSON.parse(String((post![1] as RequestInit).body))).toMatchObject({
cli: "claude",
model: "sonnet",
});
expect(seen).toEqual(["conductor:task:t_new"]);
expect(c.textContent).toContain("repeatDone");
cleanup();
});
});