diff --git a/src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts b/src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts new file mode 100644 index 0000000000..cb213ef834 --- /dev/null +++ b/src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts @@ -0,0 +1,75 @@ +/** Cloud Agent tasks → unified orchestration nodes. Pure. */ +import type { CloudAgentTask } from "@/lib/cloudAgent/types"; +import type { OrchEdge, OrchNode, OrchState } from "./orchestrationTypes"; + +const STATE_MAP: Record = { + queued: "queued", + running: "running", + awaiting_approval: "waiting_approval", + completed: "succeeded", + failed: "failed", + cancelled: "cancelled", +}; + +function truncate(s: string, n = 60): string { + return s.length > n ? `${s.slice(0, n - 1)}…` : s; +} + +export function fromCloudAgent(tasks: CloudAgentTask[]): { nodes: OrchNode[]; edges: OrchEdge[] } { + if (tasks.length === 0) return { nodes: [], edges: [] }; + const nodes: OrchNode[] = []; + const edges: OrchEdge[] = []; + const counts: Partial> = {}; + + for (const t of tasks) { + const mapped = STATE_MAP[t.status]; + const state: OrchState = mapped ?? "failed"; + counts[state] = (counts[state] ?? 0) + 1; + const id = `cloud-agent:${t.id}`; + const active = state === "running"; + nodes.push({ + id, + kind: "work", + source: "cloud-agent", + state, + label: truncate(t.prompt), + sublabel: mapped ? t.providerId : `${t.providerId} — unknown status: ${String(t.status)}`, + startedAt: t.createdAt, + updatedAt: t.updatedAt, + endedAt: t.completedAt, + cost: t.result?.cost, + raw: t, + }); + edges.push({ + id: `e:source:cloud-agent→${id}`, + from: "source:cloud-agent", + to: id, + kind: "owns", + active, + }); + + const last = t.activities[t.activities.length - 1]; + if (active && last) { + const actId = `${id}:activity`; + nodes.push({ + id: actId, + kind: "activity", + source: "cloud-agent", + state, + label: truncate(last.content), + sublabel: last.type, + updatedAt: last.timestamp, + }); + edges.push({ id: `e:${id}→${actId}`, from: id, to: actId, kind: "owns", active: true }); + } + } + + nodes.unshift({ + id: "source:cloud-agent", + kind: "source", + source: "cloud-agent", + label: "Cloud Agent", + counts, + }); + return { nodes, edges }; +} diff --git a/tests/unit/ui/orchestrationModel.test.ts b/tests/unit/ui/orchestrationModel.test.ts index 62740e1bd2..5aa06f771f 100644 --- a/tests/unit/ui/orchestrationModel.test.ts +++ b/tests/unit/ui/orchestrationModel.test.ts @@ -9,6 +9,8 @@ import { orchStateColor, } from "../../../src/app/(dashboard)/dashboard/orchestration/model/orchestrationTypes.ts"; import { STATUS_HEX } from "../../../src/shared/constants/statusColors.ts"; +import { fromCloudAgent } from "../../../src/app/(dashboard)/dashboard/orchestration/model/fromCloudAgent.ts"; +import type { CloudAgentTask } from "../../../src/lib/cloudAgent/types.ts"; describe("orchestrationTypes", () => { it("covers all six states with a color each", () => { @@ -27,3 +29,75 @@ describe("orchestrationTypes", () => { assert.equal(orchStateColor("failed"), STATUS_HEX.error); }); }); + +function caTask(over: Partial): CloudAgentTask { + return { + id: "t1", + providerId: "devin", + status: "running", + prompt: "Fix the flaky test in CI", + source: { repoName: "acme/app", repoUrl: "https://github.com/acme/app" }, + options: {}, + activities: [], + createdAt: "2026-08-30T10:00:00Z", + updatedAt: "2026-08-30T10:05:00Z", + ...over, + } as CloudAgentTask; +} + +describe("fromCloudAgent", () => { + it("maps every status to the unified OrchState", () => { + const cases: Array<[CloudAgentTask["status"], string]> = [ + ["queued", "queued"], + ["running", "running"], + ["awaiting_approval", "waiting_approval"], + ["completed", "succeeded"], + ["failed", "failed"], + ["cancelled", "cancelled"], + ]; + for (const [input, expected] of cases) { + const { nodes } = fromCloudAgent([caTask({ status: input })]); + const work = nodes.find((n) => n.kind === "work"); + assert.equal(work?.state, expected, input); + } + }); + it("unknown status becomes failed with the raw value in sublabel", () => { + const { nodes } = fromCloudAgent([caTask({ status: "exploded" as CloudAgentTask["status"] })]); + const work = nodes.find((n) => n.kind === "work"); + assert.equal(work?.state, "failed"); + assert.match(work?.sublabel ?? "", /exploded/); + }); + it("running task with activities gets one ActivityNode; completed does not", () => { + const running = caTask({ + activities: [ + { id: "a1", type: "command", content: "npm test", timestamp: "2026-08-30T10:04:00Z" }, + ], + }); + const done = caTask({ id: "t2", status: "completed", activities: running.activities }); + const { nodes, edges } = fromCloudAgent([running, done]); + const acts = nodes.filter((n) => n.kind === "activity"); + assert.equal(acts.length, 1); + assert.equal(acts[0].id, "cloud-agent:t1:activity"); + assert.ok( + edges.some( + (e) => e.from === "cloud-agent:t1" && e.to === "cloud-agent:t1:activity" && e.active + ) + ); + }); + it("emits a SourceNode with per-state counts and owns-edges from it", () => { + const { nodes, edges } = fromCloudAgent([caTask({}), caTask({ id: "t2", status: "failed" })]); + const src = nodes.find((n) => n.id === "source:cloud-agent"); + assert.equal(src?.counts?.running, 1); + assert.equal(src?.counts?.failed, 1); + assert.ok( + edges.some( + (e) => e.from === "source:cloud-agent" && e.to === "cloud-agent:t1" && e.kind === "owns" + ) + ); + }); + it("empty input emits nothing", () => { + const out = fromCloudAgent([]); + assert.equal(out.nodes.length, 0); + assert.equal(out.edges.length, 0); + }); +});