mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user