mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
feat(dashboard): pure model to compare two orchestration runs
This commit is contained in:
149
src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts
Normal file
149
src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Pure model to compare two Orchestration Canvas history runs side by side (History tab,
|
||||
* PR-A / Task A1). No React, no side effects.
|
||||
*
|
||||
* `normalizeRunSide` turns a `HistoryItem` plus its raw detail payload (the JSON body of
|
||||
* `GET /api/a2a/tasks/[id]` for `source === "a2a"`, or the in-memory `CloudAgentTask` for
|
||||
* `source === "cloud-agent"`) into a `RunSide` with a normalized `events` timeline and
|
||||
* `memoryHits` list. `detail` is untrusted: for A2A it round-trips through
|
||||
* `reconstituteHistoricalTask` (`src/app/api/a2a/tasks/[id]/route.ts`) plus client-supplied
|
||||
* `metadata` with no Zod validation, so every field is read defensively — a malformed shape
|
||||
* drops the offending item (or yields `[]`) instead of throwing. See Fase 2 lesson in
|
||||
* `historyModel.ts` / global-constraints: `memoryHits: "boom"` used to crash the drawer.
|
||||
*
|
||||
* `buildComparison` derives signed deltas (`right - left`) for `durationMs`/`cost` only when
|
||||
* both sides have a finite value, and always reports `eventCount`'s delta and whether the two
|
||||
* sides share the same (source, identity) pair.
|
||||
*/
|
||||
import type { HistoryItem } from "./historyModel";
|
||||
|
||||
export interface RunEvent {
|
||||
label: string;
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
export interface RunMemoryHit {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface RunSide {
|
||||
item: HistoryItem;
|
||||
events: RunEvent[];
|
||||
memoryHits: RunMemoryHit[];
|
||||
}
|
||||
|
||||
export interface RunDeltas {
|
||||
durationMs: number | null;
|
||||
cost: number | null;
|
||||
eventCount: number;
|
||||
sameIdentity: boolean;
|
||||
}
|
||||
|
||||
export interface RunComparison {
|
||||
left: RunSide;
|
||||
right: RunSide;
|
||||
deltas: RunDeltas;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
/** `{ timestamp: string; state: string; message?: string }` — reconstituteHistoricalTask's shape. */
|
||||
function a2aEventsFrom(detail: Record<string, unknown>): RunEvent[] {
|
||||
const events = detail.events;
|
||||
if (!Array.isArray(events)) return [];
|
||||
const out: RunEvent[] = [];
|
||||
for (const raw of events) {
|
||||
if (!isRecord(raw)) continue;
|
||||
const { state, message, timestamp } = raw;
|
||||
if (!isNonEmptyString(state)) continue;
|
||||
if (message !== undefined && typeof message !== "string") continue;
|
||||
if (timestamp !== undefined && timestamp !== null && typeof timestamp !== "string") continue;
|
||||
out.push({
|
||||
label: typeof message === "string" ? message : state,
|
||||
timestamp: typeof timestamp === "string" ? timestamp : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `CloudAgentActivity[]` (`src/lib/cloudAgent/types.ts`) — each item has `type` and `content`. */
|
||||
function cloudAgentEventsFrom(detail: Record<string, unknown>): RunEvent[] {
|
||||
const activities = detail.activities;
|
||||
if (!Array.isArray(activities)) return [];
|
||||
const out: RunEvent[] = [];
|
||||
for (const raw of activities) {
|
||||
if (!isRecord(raw)) continue;
|
||||
const { content, timestamp } = raw;
|
||||
if (!isNonEmptyString(content)) continue;
|
||||
if (timestamp !== undefined && timestamp !== null && typeof timestamp !== "string") continue;
|
||||
out.push({
|
||||
label: content,
|
||||
timestamp: typeof timestamp === "string" ? timestamp : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function memoryHitsFrom(detail: Record<string, unknown>): RunMemoryHit[] {
|
||||
const metadata = detail.metadata;
|
||||
if (!isRecord(metadata)) return [];
|
||||
const hits = metadata.memoryHits;
|
||||
if (!Array.isArray(hits)) return [];
|
||||
const out: RunMemoryHit[] = [];
|
||||
for (const raw of hits) {
|
||||
if (!isRecord(raw)) continue;
|
||||
const { id, key, type, snippet } = raw;
|
||||
if (
|
||||
typeof id === "string" &&
|
||||
typeof key === "string" &&
|
||||
typeof type === "string" &&
|
||||
typeof snippet === "string"
|
||||
) {
|
||||
out.push({ id, key, type, snippet });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one side of a comparison. NEVER throws — `detail` is untrusted JSON (persisted rows
|
||||
* for A2A history, client-supplied `metadata` with no Zod) so any shape mismatch just drops
|
||||
* the offending item; a missing/non-object `detail` yields empty `events`/`memoryHits`.
|
||||
*/
|
||||
export function normalizeRunSide(item: HistoryItem, detail: unknown): RunSide {
|
||||
if (!isRecord(detail)) {
|
||||
return { item, events: [], memoryHits: [] };
|
||||
}
|
||||
const events = item.source === "a2a" ? a2aEventsFrom(detail) : cloudAgentEventsFrom(detail);
|
||||
const memoryHits = item.source === "a2a" ? memoryHitsFrom(detail) : [];
|
||||
return { item, events, memoryHits };
|
||||
}
|
||||
|
||||
function signedDelta(left: number | null, right: number | null): number | null {
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
|
||||
return (right as number) - (left as number);
|
||||
}
|
||||
|
||||
/** Combines two normalized sides into a comparison with signed `right - left` deltas. */
|
||||
export function buildComparison(left: RunSide, right: RunSide): RunComparison {
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
deltas: {
|
||||
durationMs: signedDelta(left.item.durationMs, right.item.durationMs),
|
||||
cost: signedDelta(left.item.cost, right.item.cost),
|
||||
eventCount: right.events.length - left.events.length,
|
||||
sameIdentity:
|
||||
left.item.source === right.item.source && left.item.identity === right.item.identity,
|
||||
},
|
||||
};
|
||||
}
|
||||
98
tests/unit/ui/orchestrationCompare.test.ts
Normal file
98
tests/unit/ui/orchestrationCompare.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* tests/unit/ui/orchestrationCompare.test.ts
|
||||
* Pure model tests for comparing two Orchestration Canvas history runs (Task A1, PR-A).
|
||||
* Run: node --import tsx/esm --test tests/unit/ui/orchestrationCompare.test.ts
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildComparison,
|
||||
normalizeRunSide,
|
||||
} from "../../../src/app/(dashboard)/dashboard/orchestration/model/compareRuns.ts";
|
||||
|
||||
const a2aItem = {
|
||||
id: "a2a:1",
|
||||
source: "a2a" as const,
|
||||
identity: "smart-routing",
|
||||
state: "succeeded" as const,
|
||||
label: "smart-routing",
|
||||
createdAt: "2026-09-01T10:00:00.000Z",
|
||||
completedAt: "2026-09-01T10:00:30.000Z",
|
||||
durationMs: 30000,
|
||||
cost: null,
|
||||
raw: {},
|
||||
};
|
||||
const caItem = {
|
||||
id: "cloud-agent:9",
|
||||
source: "cloud-agent" as const,
|
||||
identity: "jules",
|
||||
state: "succeeded" as const,
|
||||
label: "fix login",
|
||||
createdAt: "2026-09-01T10:00:00.000Z",
|
||||
completedAt: "2026-09-01T10:00:10.000Z",
|
||||
durationMs: 10000,
|
||||
cost: 0.5,
|
||||
raw: {},
|
||||
};
|
||||
|
||||
test("normalizeRunSide reads a2a events from the historical task shape", () => {
|
||||
const side = normalizeRunSide(a2aItem, {
|
||||
events: [
|
||||
{ timestamp: "2026-09-01T10:00:00.000Z", state: "submitted" },
|
||||
{ timestamp: "2026-09-01T10:00:05.000Z", state: "working", message: "started" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(side.events, [
|
||||
{ label: "submitted", timestamp: "2026-09-01T10:00:00.000Z" },
|
||||
{ label: "started", timestamp: "2026-09-01T10:00:05.000Z" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeRunSide reads cloud-agent activities", () => {
|
||||
const side = normalizeRunSide(caItem, {
|
||||
activities: [
|
||||
{ id: "a", type: "log", content: "cloning", timestamp: "2026-09-01T10:00:01.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(side.events, [{ label: "cloning", timestamp: "2026-09-01T10:00:01.000Z" }]);
|
||||
});
|
||||
|
||||
test("normalizeRunSide never throws on malformed detail", () => {
|
||||
for (const bad of [null, undefined, "boom", 42, { events: "nope" }, { events: [null, 7, {}] }]) {
|
||||
const side = normalizeRunSide(a2aItem, bad);
|
||||
assert.deepEqual(side.events, []);
|
||||
assert.deepEqual(side.memoryHits, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeRunSide keeps only well-formed memoryHits", () => {
|
||||
const side = normalizeRunSide(a2aItem, {
|
||||
events: [],
|
||||
metadata: {
|
||||
memoryHits: [
|
||||
{ id: "m1", key: "k", type: "factual", snippet: "s" },
|
||||
{ id: "m2", key: { a: 1 }, type: "factual", snippet: "s" },
|
||||
"nope",
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(side.memoryHits.length, 1);
|
||||
assert.equal(side.memoryHits[0].id, "m1");
|
||||
});
|
||||
|
||||
test("buildComparison computes signed deltas and sameIdentity", () => {
|
||||
const left = normalizeRunSide(a2aItem, { events: [{ timestamp: null, state: "submitted" }] });
|
||||
const right = normalizeRunSide({ ...a2aItem, id: "a2a:2", durationMs: 45000 }, { events: [] });
|
||||
const cmp = buildComparison(left, right);
|
||||
assert.equal(cmp.deltas.durationMs, 15000);
|
||||
assert.equal(cmp.deltas.eventCount, -1);
|
||||
assert.equal(cmp.deltas.sameIdentity, true);
|
||||
});
|
||||
|
||||
test("buildComparison returns null delta when either side lacks the value", () => {
|
||||
const left = normalizeRunSide(a2aItem, {}); // cost null
|
||||
const right = normalizeRunSide(caItem, {}); // cost 0.5
|
||||
const cmp = buildComparison(left, right);
|
||||
assert.equal(cmp.deltas.cost, null);
|
||||
assert.equal(cmp.deltas.sameIdentity, false);
|
||||
});
|
||||
Reference in New Issue
Block a user