feat: add workflow console lifecycle explorer
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ExecutionView } from "./ExecutionView.js";
|
||||
import type { TraceFrameView } from "./trace-model.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockFrames: TraceFrameView[] = [
|
||||
{
|
||||
nodeId: "start",
|
||||
stepType: "use",
|
||||
outcome: "ok",
|
||||
inputSummary: "{}",
|
||||
outputSummary: "{ report_id: string }",
|
||||
stateChangeCount: 0,
|
||||
raw: {},
|
||||
},
|
||||
{
|
||||
nodeId: "review",
|
||||
stepType: "interrupt",
|
||||
outcome: "submitted",
|
||||
inputSummary: "{ report: string }",
|
||||
outputSummary: "{}",
|
||||
stateChangeCount: 1,
|
||||
raw: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockInterrupt = {
|
||||
kind: "human",
|
||||
payload: { report: "Please review" },
|
||||
outcomes: ["submitted", "rejected"],
|
||||
requestSchema: { type: "object", properties: { decision: { type: "string" } } },
|
||||
resumeSchema: { type: "object", properties: { decision: { type: "string" } } },
|
||||
typed: true,
|
||||
};
|
||||
|
||||
describe("ExecutionView", () => {
|
||||
it("renders trace frames", () => {
|
||||
render(<ExecutionView frames={mockFrames} />);
|
||||
expect(screen.getByText("start")).toBeInTheDocument();
|
||||
expect(screen.getByText("review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows step types", () => {
|
||||
render(<ExecutionView frames={mockFrames} />);
|
||||
expect(screen.getAllByText("use").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("interrupt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows outcomes", () => {
|
||||
render(<ExecutionView frames={mockFrames} />);
|
||||
expect(screen.getAllByText("ok").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("submitted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelectNode when frame is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ExecutionView frames={mockFrames} onSelectNode={onSelect} />);
|
||||
const reviewNodes = screen.getAllByText("review");
|
||||
fireEvent.click(reviewNodes[0]!);
|
||||
expect(onSelect).toHaveBeenCalledWith("review");
|
||||
});
|
||||
|
||||
it("renders interrupt details", () => {
|
||||
render(<ExecutionView frames={mockFrames} interrupt={mockInterrupt} />);
|
||||
expect(screen.getByText(/human/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/submitted, rejected/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no frames", () => {
|
||||
render(<ExecutionView frames={[]} />);
|
||||
expect(screen.getByText(/no frames/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TraceFrameView } from "./trace-model.js";
|
||||
|
||||
type InterruptInfo = {
|
||||
readonly kind: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly outcomes: ReadonlyArray<string>;
|
||||
readonly requestSchema: Record<string, unknown>;
|
||||
readonly resumeSchema: Record<string, unknown>;
|
||||
readonly typed: boolean;
|
||||
};
|
||||
|
||||
type ExecutionViewProps = {
|
||||
readonly frames: ReadonlyArray<TraceFrameView>;
|
||||
readonly interrupt?: InterruptInfo | null;
|
||||
readonly onSelectNode?: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
export const ExecutionView = ({ frames, interrupt = null, onSelectNode }: ExecutionViewProps) => {
|
||||
if (frames.length === 0) {
|
||||
return (
|
||||
<div className="execution-view execution-view--empty">
|
||||
No frames in this trace
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="execution-view">
|
||||
<div className="execution-view__frames">
|
||||
<h3>Trace Frames</h3>
|
||||
<ul className="frame-list">
|
||||
{frames.map((frame, index) => (
|
||||
<li
|
||||
key={`${frame.nodeId}-${index}`}
|
||||
className="frame-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelectNode?.(frame.nodeId)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onSelectNode?.(frame.nodeId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="frame-item__node">{frame.nodeId}</span>
|
||||
<span className="frame-item__type">{frame.stepType}</span>
|
||||
<span className="frame-item__outcome">{frame.outcome}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{interrupt && (
|
||||
<div className="execution-view__interrupt">
|
||||
<h3>Interrupt Block</h3>
|
||||
<dl>
|
||||
<dt>Kind</dt>
|
||||
<dd>{interrupt.kind}</dd>
|
||||
<dt>Outcomes</dt>
|
||||
<dd>{interrupt.outcomes.join(", ")}</dd>
|
||||
<dt>Typed</dt>
|
||||
<dd>{interrupt.typed ? "Yes" : "No"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildTraceFrames, type TraceFrameView } from "./trace-model.js";
|
||||
|
||||
const sampleTracePage = {
|
||||
frames: [
|
||||
{
|
||||
nodeId: "start",
|
||||
stepType: "use",
|
||||
resolvedInput: {},
|
||||
outcome: "ok",
|
||||
output: { report_id: "rpt_1" },
|
||||
stateChanges: {},
|
||||
},
|
||||
{
|
||||
nodeId: "review",
|
||||
stepType: "interrupt",
|
||||
resolvedInput: { report: "..." },
|
||||
outcome: "submitted",
|
||||
output: {},
|
||||
stateChanges: { status: "reviewed" },
|
||||
},
|
||||
{
|
||||
nodeId: "create_issues",
|
||||
stepType: "use",
|
||||
resolvedInput: { report_id: "rpt_1" },
|
||||
outcome: "ok",
|
||||
output: { issues_created: 3 },
|
||||
stateChanges: {},
|
||||
},
|
||||
],
|
||||
traceStart: 0,
|
||||
traceLimit: 50,
|
||||
traceTruncated: false,
|
||||
};
|
||||
|
||||
describe("buildTraceFrames", () => {
|
||||
it("maps node ids correctly", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames.map((f) => f.nodeId)).toEqual([
|
||||
"start",
|
||||
"review",
|
||||
"create_issues",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps step types correctly", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames.map((f) => f.stepType)).toEqual(["use", "interrupt", "use"]);
|
||||
});
|
||||
|
||||
it("maps outcomes correctly", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames.map((f) => f.outcome)).toEqual(["ok", "submitted", "ok"]);
|
||||
});
|
||||
|
||||
it("produces concise input summaries", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames[1]!.inputSummary).toContain("report");
|
||||
});
|
||||
|
||||
it("produces concise output summaries", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames[2]!.outputSummary).toContain("issues_created");
|
||||
});
|
||||
|
||||
it("preserves original trace page immutability", () => {
|
||||
const original = structuredClone(sampleTracePage);
|
||||
buildTraceFrames(sampleTracePage);
|
||||
expect(sampleTracePage).toEqual(original);
|
||||
});
|
||||
|
||||
it("handles empty trace page", () => {
|
||||
const result = buildTraceFrames({
|
||||
frames: [],
|
||||
traceStart: 0,
|
||||
traceLimit: 50,
|
||||
traceTruncated: false,
|
||||
});
|
||||
expect(result.frames).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes state change count", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.frames[1]!.stateChangeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns pagination info", () => {
|
||||
const result = buildTraceFrames(sampleTracePage);
|
||||
expect(result.traceStart).toBe(0);
|
||||
expect(result.traceLimit).toBe(50);
|
||||
expect(result.traceTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type TraceFrameView = {
|
||||
readonly nodeId: string;
|
||||
readonly stepType: string;
|
||||
readonly outcome: string;
|
||||
readonly inputSummary: string;
|
||||
readonly outputSummary: string;
|
||||
readonly stateChangeCount: number;
|
||||
readonly raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TraceFrame = {
|
||||
readonly nodeId: string;
|
||||
readonly stepType: string;
|
||||
readonly resolvedInput: Record<string, unknown>;
|
||||
readonly outcome: string;
|
||||
readonly output: Record<string, unknown>;
|
||||
readonly stateChanges: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TracePage = {
|
||||
readonly frames: ReadonlyArray<TraceFrame>;
|
||||
readonly traceStart: number;
|
||||
readonly traceLimit: number;
|
||||
readonly traceTruncated: boolean;
|
||||
};
|
||||
|
||||
type TraceFramesResult = {
|
||||
readonly frames: ReadonlyArray<TraceFrameView>;
|
||||
readonly traceStart: number;
|
||||
readonly traceLimit: number;
|
||||
readonly traceTruncated: boolean;
|
||||
};
|
||||
|
||||
const summarizeObject = (obj: Record<string, unknown>, maxKeys = 3): string => {
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return "{}";
|
||||
const displayed = keys.slice(0, maxKeys);
|
||||
const parts = displayed.map((k) => `${k}: ${typeof obj[k]}`);
|
||||
if (keys.length > maxKeys) {
|
||||
parts.push(`+${keys.length - maxKeys} more`);
|
||||
}
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
};
|
||||
|
||||
export const buildTraceFrames = (page: TracePage): TraceFramesResult => {
|
||||
const frames: TraceFrameView[] = page.frames.map((frame) => ({
|
||||
nodeId: frame.nodeId,
|
||||
stepType: frame.stepType,
|
||||
outcome: frame.outcome,
|
||||
inputSummary: summarizeObject(frame.resolvedInput),
|
||||
outputSummary: summarizeObject(frame.output),
|
||||
stateChangeCount: Object.keys(frame.stateChanges).length,
|
||||
raw: frame as unknown as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
return {
|
||||
frames,
|
||||
traceStart: page.traceStart,
|
||||
traceLimit: page.traceLimit,
|
||||
traceTruncated: page.traceTruncated,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user