feat: add workflow console lifecycle explorer

This commit is contained in:
lda
2026-07-02 21:43:04 +07:00 Verified
parent 2cae5b59eb
commit 3cdcf6ff3b
31 changed files with 4457 additions and 12 deletions
@@ -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,
};
};