feat: add workflow console lifecycle explorer
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { LifecycleExplorer } from "./LifecycleExplorer.js";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
import type { LifecycleExplorerController } from "./useLifecycleExplorer.js";
|
||||
import type { LifecycleState } from "./state.js";
|
||||
|
||||
const createMockController = (
|
||||
overrides: Partial<LifecycleState> = {},
|
||||
): LifecycleExplorerController => ({
|
||||
state: {
|
||||
artifactList: { phase: "idle" },
|
||||
deploymentList: { phase: "idle" },
|
||||
runList: { phase: "idle" },
|
||||
selectedArtifactId: null,
|
||||
artifactDetail: null,
|
||||
selectedDeploymentId: null,
|
||||
deploymentDetail: null,
|
||||
deploymentValidation: null,
|
||||
selectedRunId: null,
|
||||
runDetail: null,
|
||||
trace: null,
|
||||
rawEvidence: [],
|
||||
errors: [],
|
||||
...overrides,
|
||||
},
|
||||
selectArtifact: vi.fn(),
|
||||
selectDeployment: vi.fn(),
|
||||
selectRun: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
loadMoreArtifacts: vi.fn(),
|
||||
loadMoreRuns: vi.fn(),
|
||||
loadTrace: vi.fn(),
|
||||
});
|
||||
|
||||
describe("LifecycleExplorer", () => {
|
||||
it("renders artifact buttons when loaded", () => {
|
||||
const controller = createMockController({
|
||||
artifactList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{
|
||||
key: "report@1",
|
||||
artifactId: "report",
|
||||
version: 1,
|
||||
kind: "workflow",
|
||||
displayName: "Report",
|
||||
description: null,
|
||||
outcomes: ["ok"],
|
||||
requiredSources: ["local.report"],
|
||||
diagnosticCount: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
nextCursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
expect(screen.getByRole("option", { name: /Report version 1/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders deployment buttons when loaded", () => {
|
||||
const controller = createMockController({
|
||||
deploymentList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{
|
||||
id: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
bindingCount: 1,
|
||||
driftPolicy: "block",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
expect(screen.getByRole("option", { name: /report.default/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("renders run buttons when loaded", () => {
|
||||
const controller = createMockController({
|
||||
runList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{
|
||||
runId: "run_1",
|
||||
deploymentId: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
status: "interrupted",
|
||||
resumeReadiness: "ready",
|
||||
diagnosticCount: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
nextCursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
expect(screen.getByRole("option", { name: /run_1 interrupted/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("calls selectArtifact when artifact is clicked", () => {
|
||||
const controller = createMockController({
|
||||
artifactList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{
|
||||
key: "report@1",
|
||||
artifactId: "report",
|
||||
version: 1,
|
||||
kind: "workflow",
|
||||
displayName: "Report",
|
||||
description: null,
|
||||
outcomes: ["ok"],
|
||||
requiredSources: ["local.report"],
|
||||
diagnosticCount: 0,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
nextCursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
fireEvent.click(screen.getAllByRole("option", { name: /Report version 1/i })[0]!);
|
||||
expect(controller.selectArtifact).toHaveBeenCalledWith("report@1");
|
||||
});
|
||||
|
||||
it("shows empty state when no artifacts", () => {
|
||||
const controller = createMockController({
|
||||
artifactList: { phase: "loaded", value: { items: [], total: 0, nextCursor: null } },
|
||||
});
|
||||
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { LifecycleExplorerController } from "./useLifecycleExplorer.js";
|
||||
import { RecordColumns } from "./RecordColumns.js";
|
||||
import { RecordDetails } from "./RecordDetails.js";
|
||||
import { buildWorkflowGraph } from "../graph/graph-model.js";
|
||||
import { WorkflowGraph } from "../graph/WorkflowGraph.js";
|
||||
import { buildTraceFrames } from "../execution/trace-model.js";
|
||||
import { ExecutionView } from "../execution/ExecutionView.js";
|
||||
|
||||
type LifecycleExplorerProps = {
|
||||
readonly controller: LifecycleExplorerController;
|
||||
};
|
||||
|
||||
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
|
||||
|
||||
export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
|
||||
const { state } = controller;
|
||||
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
|
||||
|
||||
const artifacts =
|
||||
state.artifactList.phase === "loaded" ? state.artifactList.value.items : [];
|
||||
const deployments =
|
||||
state.deploymentList.phase === "loaded"
|
||||
? state.deploymentList.value.items
|
||||
: [];
|
||||
const runs =
|
||||
state.runList.phase === "loaded" ? state.runList.value.items : [];
|
||||
|
||||
const graphModel = useMemo(() => {
|
||||
if (!state.artifactDetail?.plan) return null;
|
||||
const plan = state.artifactDetail.plan as {
|
||||
nodes: ReadonlyArray<Record<string, unknown>>;
|
||||
edges: ReadonlyArray<Record<string, unknown>>;
|
||||
};
|
||||
if (!plan.nodes || !plan.edges) return null;
|
||||
return buildWorkflowGraph(plan);
|
||||
}, [state.artifactDetail?.plan]);
|
||||
|
||||
const traceResult = useMemo(() => {
|
||||
if (!state.trace) return null;
|
||||
return buildTraceFrames(state.trace);
|
||||
}, [state.trace]);
|
||||
|
||||
return (
|
||||
<div className="lifecycle-explorer">
|
||||
<nav className="focus-nav" aria-label="Focus modes">
|
||||
<button
|
||||
onClick={() => setFocusMode("lifecycle")}
|
||||
className={focusMode === "lifecycle" ? "active" : ""}
|
||||
>
|
||||
Lifecycle
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocusMode("graph")}
|
||||
disabled={!graphModel}
|
||||
className={focusMode === "graph" ? "active" : ""}
|
||||
>
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocusMode("execution")}
|
||||
disabled={!traceResult}
|
||||
className={focusMode === "execution" ? "active" : ""}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocusMode("raw")}
|
||||
className={focusMode === "raw" ? "active" : ""}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{focusMode === "lifecycle" && (
|
||||
<div className="lifecycle-content">
|
||||
<RecordColumns
|
||||
artifacts={artifacts}
|
||||
deployments={deployments}
|
||||
runs={runs}
|
||||
selectedArtifactId={state.selectedArtifactId}
|
||||
selectedDeploymentId={state.selectedDeploymentId}
|
||||
selectedRunId={state.selectedRunId}
|
||||
onSelectArtifact={controller.selectArtifact}
|
||||
onSelectDeployment={controller.selectDeployment}
|
||||
onSelectRun={controller.selectRun}
|
||||
onLoadMoreArtifacts={controller.loadMoreArtifacts}
|
||||
hasMoreArtifacts={state.artifactList.phase === "loaded" && state.artifactList.value.nextCursor !== null}
|
||||
onLoadMoreRuns={controller.loadMoreRuns}
|
||||
hasMoreRuns={state.runList.phase === "loaded" && state.runList.value.nextCursor !== null}
|
||||
/>
|
||||
<RecordDetails
|
||||
artifactDetail={state.artifactDetail}
|
||||
deploymentDetail={state.deploymentDetail}
|
||||
runDetail={state.runDetail}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusMode === "graph" && graphModel && (
|
||||
<div className="graph-content">
|
||||
<WorkflowGraph model={graphModel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusMode === "execution" && traceResult && (
|
||||
<div className="execution-content">
|
||||
<ExecutionView
|
||||
frames={traceResult.frames}
|
||||
interrupt={state.runDetail?.interrupt ? {
|
||||
kind: state.runDetail.interrupt.kind,
|
||||
payload: state.runDetail.interrupt.payload,
|
||||
outcomes: state.runDetail.interrupt.outcomes,
|
||||
requestSchema: {},
|
||||
resumeSchema: {},
|
||||
typed: false,
|
||||
} : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusMode === "raw" && (
|
||||
<div className="raw-content">
|
||||
<h3>Protocol Evidence</h3>
|
||||
{state.rawEvidence.length === 0 ? (
|
||||
<p className="empty-state">No evidence recorded yet.</p>
|
||||
) : (
|
||||
<ul className="evidence-list">
|
||||
{state.rawEvidence.map((record) => (
|
||||
<li key={record.id}>
|
||||
<span className="evidence-op">{record.operation}</span>
|
||||
<span className="evidence-label">{record.label}</span>
|
||||
<span className="evidence-duration">{record.durationMs}ms</span>
|
||||
<details>
|
||||
<summary>Equivalent CLI</summary>
|
||||
<pre><code>{record.equivalentCli}</code></pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Request</summary>
|
||||
<pre><code>{JSON.stringify(record.request, null, 2)}</code></pre>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Response</summary>
|
||||
<pre><code>{JSON.stringify(record.response, null, 2)}</code></pre>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
|
||||
|
||||
type RecordColumnsProps = {
|
||||
readonly artifacts: ArtifactSummary[];
|
||||
readonly deployments: DeploymentSummary[];
|
||||
readonly runs: RunSummary[];
|
||||
readonly selectedArtifactId: string | null;
|
||||
readonly selectedDeploymentId: string | null;
|
||||
readonly selectedRunId: string | null;
|
||||
readonly onSelectArtifact: (artifactId: string | null) => void;
|
||||
readonly onSelectDeployment: (deploymentId: string | null) => void;
|
||||
readonly onSelectRun: (runId: string | null) => void;
|
||||
readonly onLoadMoreArtifacts?: () => void;
|
||||
readonly hasMoreArtifacts?: boolean;
|
||||
readonly onLoadMoreRuns?: () => void;
|
||||
readonly hasMoreRuns?: boolean;
|
||||
};
|
||||
|
||||
export const RecordColumns = ({
|
||||
artifacts,
|
||||
deployments,
|
||||
runs,
|
||||
selectedArtifactId,
|
||||
selectedDeploymentId,
|
||||
selectedRunId,
|
||||
onSelectArtifact,
|
||||
onSelectDeployment,
|
||||
onSelectRun,
|
||||
onLoadMoreArtifacts,
|
||||
hasMoreArtifacts,
|
||||
onLoadMoreRuns,
|
||||
hasMoreRuns,
|
||||
}: RecordColumnsProps) => (
|
||||
<div className="lifecycle-columns">
|
||||
<div className="lifecycle-column">
|
||||
<h3>Artifacts</h3>
|
||||
{artifacts.length === 0 ? (
|
||||
<p className="empty-state">No artifacts</p>
|
||||
) : (
|
||||
<ul role="listbox" aria-label="Artifacts">
|
||||
{artifacts.map((artifact) => (
|
||||
<li key={artifact.key}>
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={selectedArtifactId === artifact.key}
|
||||
onClick={() => onSelectArtifact(artifact.key)}
|
||||
className={selectedArtifactId === artifact.key ? "selected" : ""}
|
||||
>
|
||||
{artifact.displayName} version {artifact.version}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hasMoreArtifacts && onLoadMoreArtifacts && (
|
||||
<button type="button" onClick={onLoadMoreArtifacts} className="load-more">
|
||||
Load more artifacts
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="lifecycle-column">
|
||||
<h3>Deployments</h3>
|
||||
{deployments.length === 0 ? (
|
||||
<p className="empty-state">No deployments</p>
|
||||
) : (
|
||||
<ul role="listbox" aria-label="Deployments">
|
||||
{deployments.map((deployment) => (
|
||||
<li key={deployment.id}>
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={selectedDeploymentId === deployment.id}
|
||||
onClick={() => onSelectDeployment(deployment.id)}
|
||||
className={selectedDeploymentId === deployment.id ? "selected" : ""}
|
||||
>
|
||||
{deployment.id}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="lifecycle-column">
|
||||
<h3>Runs</h3>
|
||||
{runs.length === 0 ? (
|
||||
<p className="empty-state">No runs</p>
|
||||
) : (
|
||||
<ul role="listbox" aria-label="Runs">
|
||||
{runs.map((run) => (
|
||||
<li key={run.runId}>
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={selectedRunId === run.runId}
|
||||
onClick={() => onSelectRun(run.runId)}
|
||||
className={selectedRunId === run.runId ? "selected" : ""}
|
||||
>
|
||||
{run.runId} {run.status}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hasMoreRuns && onLoadMoreRuns && (
|
||||
<button type="button" onClick={onLoadMoreRuns} className="load-more">
|
||||
Load more runs
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ArtifactDetail, DeploymentDetail, RunDetail } from "./models.js";
|
||||
|
||||
type RecordDetailsProps = {
|
||||
readonly artifactDetail: ArtifactDetail | null;
|
||||
readonly deploymentDetail: DeploymentDetail | null;
|
||||
readonly runDetail: RunDetail | null;
|
||||
};
|
||||
|
||||
export const RecordDetails = ({
|
||||
artifactDetail,
|
||||
deploymentDetail,
|
||||
runDetail,
|
||||
}: RecordDetailsProps) => (
|
||||
<div className="record-details">
|
||||
{artifactDetail && (
|
||||
<section aria-label="Artifact details">
|
||||
<h3>Artifact</h3>
|
||||
<dl>
|
||||
<dt>Name</dt>
|
||||
<dd>{artifactDetail.title}</dd>
|
||||
<dt>ID</dt>
|
||||
<dd>{artifactDetail.artifactId}</dd>
|
||||
<dt>Version</dt>
|
||||
<dd>{artifactDetail.version}</dd>
|
||||
<dt>Kind</dt>
|
||||
<dd>{artifactDetail.kind}</dd>
|
||||
<dt>Outcomes</dt>
|
||||
<dd>{artifactDetail.outcomes.join(", ")}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
{deploymentDetail && (
|
||||
<section aria-label="Deployment details">
|
||||
<h3>Deployment</h3>
|
||||
<dl>
|
||||
<dt>ID</dt>
|
||||
<dd>{deploymentDetail.id}</dd>
|
||||
<dt>Artifact ID</dt>
|
||||
<dd>{deploymentDetail.artifactId}</dd>
|
||||
<dt>Artifact Version</dt>
|
||||
<dd>{deploymentDetail.artifactVersion}</dd>
|
||||
<dt>Drift Policy</dt>
|
||||
<dd>{deploymentDetail.driftPolicy}</dd>
|
||||
<dt>Bindings</dt>
|
||||
<dd>{deploymentDetail.bindings.length}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
{runDetail && (
|
||||
<section aria-label="Run details">
|
||||
<h3>Run</h3>
|
||||
<dl>
|
||||
<dt>ID</dt>
|
||||
<dd>{runDetail.runId}</dd>
|
||||
<dt>Deployment</dt>
|
||||
<dd>{runDetail.deploymentId}</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>{runDetail.status}</dd>
|
||||
<dt>Resume Readiness</dt>
|
||||
<dd>{runDetail.resumeReadiness}</dd>
|
||||
</dl>
|
||||
{runDetail.interrupt && (
|
||||
<div className="interrupt-details">
|
||||
<h4>Interrupt</h4>
|
||||
<dl>
|
||||
<dt>Kind</dt>
|
||||
<dd>{runDetail.interrupt.kind}</dd>
|
||||
<dt>Outcomes</dt>
|
||||
<dd>{runDetail.interrupt.outcomes.join(", ")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{!artifactDetail && !deploymentDetail && !runDetail && (
|
||||
<p className="empty-state">Select a record to view details</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
decodeArtifactList,
|
||||
decodeArtifactDetail,
|
||||
decodeDeploymentList,
|
||||
decodeDeploymentDetail,
|
||||
decodeDeploymentValidation,
|
||||
decodeRunList,
|
||||
decodeRunDetail,
|
||||
decodeTracePage,
|
||||
} from "./models.js";
|
||||
|
||||
describe("decodeArtifactList", () => {
|
||||
it("decodes an artifact list into immutable summaries", () => {
|
||||
const result = decodeArtifactList({
|
||||
items: [
|
||||
{
|
||||
key: "report@1",
|
||||
artifactId: "report",
|
||||
version: 1,
|
||||
kind: "workflow",
|
||||
displayName: "Report",
|
||||
description: null,
|
||||
outcomes: ["ok"],
|
||||
requiredSources: ["local.report"],
|
||||
diagnosticCount: 0,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
expect(result.items[0]?.key).toBe("report@1");
|
||||
expect(result.items[0]?.artifactId).toBe("report");
|
||||
expect(result.items[0]?.version).toBe(1);
|
||||
});
|
||||
|
||||
it("handles empty list", () => {
|
||||
const result = decodeArtifactList({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
expect(result.items).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeArtifactDetail", () => {
|
||||
it("decodes an artifact detail with plan", () => {
|
||||
const result = decodeArtifactDetail({
|
||||
artifactId: "report",
|
||||
version: 1,
|
||||
title: "Report",
|
||||
kind: "workflow",
|
||||
description: null,
|
||||
outcomes: ["ok"],
|
||||
plan: { nodes: [], edges: [] },
|
||||
requiredCapabilities: [],
|
||||
workflowDependencies: {},
|
||||
createdFromCatalogVersion: null,
|
||||
});
|
||||
expect(result.artifactId).toBe("report");
|
||||
expect(result.title).toBe("Report");
|
||||
expect(result.plan).toEqual({ nodes: [], edges: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeDeploymentList", () => {
|
||||
it("decodes a deployment list", () => {
|
||||
const result = decodeDeploymentList({
|
||||
items: [
|
||||
{
|
||||
id: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
bindingCount: 1,
|
||||
driftPolicy: "block",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.items[0]?.id).toBe("report.default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeDeploymentDetail", () => {
|
||||
it("decodes a deployment detail", () => {
|
||||
const result = decodeDeploymentDetail({
|
||||
id: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
bindings: [{ logicalSource: "local.report", concreteSource: "report" }],
|
||||
driftPolicy: "block",
|
||||
});
|
||||
expect(result.id).toBe("report.default");
|
||||
expect(result.bindings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeDeploymentValidation", () => {
|
||||
it("decodes a deployment validation result", () => {
|
||||
const result = decodeDeploymentValidation({
|
||||
deploymentId: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
status: "runnable",
|
||||
diagnostics: [],
|
||||
nextActions: {
|
||||
canContinue: true,
|
||||
canSaveNow: null,
|
||||
recommendedNextTool: null,
|
||||
reason: "deployment is valid",
|
||||
patchExamples: [],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe("runnable");
|
||||
expect(result.nextActions.canContinue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeRunList", () => {
|
||||
it("decodes a run list", () => {
|
||||
const result = decodeRunList({
|
||||
items: [
|
||||
{
|
||||
runId: "run_1",
|
||||
deploymentId: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
status: "interrupted",
|
||||
resumeReadiness: "ready",
|
||||
diagnosticCount: 0,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
total: 1,
|
||||
});
|
||||
expect(result.items[0]?.runId).toBe("run_1");
|
||||
expect(result.items[0]?.status).toBe("interrupted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeRunDetail", () => {
|
||||
it("decodes a run detail with interrupt", () => {
|
||||
const result = decodeRunDetail({
|
||||
runId: "run_1",
|
||||
deploymentId: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
status: "interrupted",
|
||||
resumeReadiness: "ready",
|
||||
interrupt: { kind: "review", payload: {}, outcomes: [] },
|
||||
outcome: null,
|
||||
error: null,
|
||||
output: null,
|
||||
diagnostics: [],
|
||||
traceCount: 0,
|
||||
nextActions: {
|
||||
canContinue: false,
|
||||
canSaveNow: null,
|
||||
recommendedNextTool: null,
|
||||
reason: "run is interrupted",
|
||||
patchExamples: [],
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
expect(result.interrupt?.kind).toBe("review");
|
||||
expect(result.traceCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeTracePage", () => {
|
||||
it("decodes a trace page", () => {
|
||||
const result = decodeTracePage({
|
||||
frames: [
|
||||
{
|
||||
nodeId: "review",
|
||||
stepType: "interrupt",
|
||||
outcome: "submitted",
|
||||
resolvedInput: {},
|
||||
output: {},
|
||||
stateChanges: {},
|
||||
},
|
||||
],
|
||||
traceStart: 0,
|
||||
traceLimit: 50,
|
||||
traceTruncated: false,
|
||||
});
|
||||
expect(result.frames[0]?.nodeId).toBe("review");
|
||||
expect(result.traceTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as v from "valibot";
|
||||
|
||||
const decode = <T>(
|
||||
label: string,
|
||||
schema: v.GenericSchema<unknown, T>,
|
||||
value: unknown,
|
||||
): T => {
|
||||
const result = v.safeParse(schema, value);
|
||||
if (result.success) return result.output;
|
||||
throw new Error(
|
||||
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
|
||||
);
|
||||
};
|
||||
|
||||
// Artifact schemas
|
||||
const ArtifactSummarySchema = v.object({
|
||||
key: v.string(),
|
||||
artifactId: v.string(),
|
||||
version: v.number(),
|
||||
kind: v.string(),
|
||||
displayName: v.string(),
|
||||
description: v.nullish(v.string(), null),
|
||||
outcomes: v.array(v.string()),
|
||||
requiredSources: v.array(v.string()),
|
||||
diagnosticCount: v.number(),
|
||||
});
|
||||
|
||||
const ArtifactListSchema = v.object({
|
||||
items: v.array(ArtifactSummarySchema),
|
||||
nextCursor: v.nullish(v.string(), null),
|
||||
total: v.number(),
|
||||
});
|
||||
|
||||
const ArtifactDetailSchema = v.object({
|
||||
artifactId: v.string(),
|
||||
version: v.number(),
|
||||
title: v.string(),
|
||||
kind: v.string(),
|
||||
description: v.nullish(v.string(), null),
|
||||
outcomes: v.array(v.string()),
|
||||
plan: v.record(v.string(), v.unknown()),
|
||||
requiredCapabilities: v.unknown(),
|
||||
workflowDependencies: v.record(v.string(), v.number()),
|
||||
createdFromCatalogVersion: v.nullish(v.string(), null),
|
||||
});
|
||||
|
||||
// Deployment schemas
|
||||
const DeploymentBindingSchema = v.object({
|
||||
logicalSource: v.string(),
|
||||
concreteSource: v.string(),
|
||||
});
|
||||
|
||||
const DeploymentSummarySchema = v.object({
|
||||
id: v.string(),
|
||||
artifactId: v.string(),
|
||||
artifactVersion: v.number(),
|
||||
bindingCount: v.number(),
|
||||
driftPolicy: v.string(),
|
||||
});
|
||||
|
||||
const DeploymentListSchema = v.object({
|
||||
items: v.array(DeploymentSummarySchema),
|
||||
});
|
||||
|
||||
const DeploymentDetailSchema = v.object({
|
||||
id: v.string(),
|
||||
artifactId: v.string(),
|
||||
artifactVersion: v.number(),
|
||||
bindings: v.array(DeploymentBindingSchema),
|
||||
driftPolicy: v.string(),
|
||||
});
|
||||
|
||||
const DeploymentValidationSchema = v.object({
|
||||
deploymentId: v.string(),
|
||||
artifactId: v.string(),
|
||||
artifactVersion: v.number(),
|
||||
status: v.union([v.literal("runnable"), v.literal("unrunnable")]),
|
||||
diagnostics: v.array(v.unknown()),
|
||||
nextActions: v.object({
|
||||
canContinue: v.boolean(),
|
||||
canSaveNow: v.nullish(v.boolean(), null),
|
||||
recommendedNextTool: v.nullish(v.string(), null),
|
||||
reason: v.string(),
|
||||
patchExamples: v.array(v.unknown()),
|
||||
warnings: v.array(v.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
// Run schemas
|
||||
const RunSummarySchema = v.object({
|
||||
runId: v.string(),
|
||||
deploymentId: v.string(),
|
||||
artifactId: v.string(),
|
||||
artifactVersion: v.number(),
|
||||
status: v.string(),
|
||||
resumeReadiness: v.string(),
|
||||
diagnosticCount: v.number(),
|
||||
});
|
||||
|
||||
const RunInterruptSchema = v.object({
|
||||
kind: v.string(),
|
||||
payload: v.record(v.string(), v.unknown()),
|
||||
outcomes: v.array(v.string()),
|
||||
});
|
||||
|
||||
const RunListSchema = v.object({
|
||||
items: v.array(RunSummarySchema),
|
||||
nextCursor: v.nullish(v.string(), null),
|
||||
total: v.number(),
|
||||
});
|
||||
|
||||
const RunDetailSchema = v.object({
|
||||
runId: v.string(),
|
||||
deploymentId: v.string(),
|
||||
artifactId: v.string(),
|
||||
artifactVersion: v.number(),
|
||||
status: v.string(),
|
||||
resumeReadiness: v.string(),
|
||||
interrupt: v.nullish(RunInterruptSchema, null),
|
||||
outcome: v.nullish(v.string(), null),
|
||||
error: v.nullish(v.string(), null),
|
||||
output: v.nullish(v.record(v.string(), v.unknown()), null),
|
||||
diagnostics: v.array(v.unknown()),
|
||||
traceCount: v.number(),
|
||||
nextActions: v.object({
|
||||
canContinue: v.boolean(),
|
||||
canSaveNow: v.nullish(v.boolean(), null),
|
||||
recommendedNextTool: v.nullish(v.string(), null),
|
||||
reason: v.string(),
|
||||
patchExamples: v.array(v.unknown()),
|
||||
warnings: v.array(v.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
// Trace schemas
|
||||
const TraceFrameSchema = v.object({
|
||||
nodeId: v.string(),
|
||||
stepType: v.string(),
|
||||
outcome: v.string(),
|
||||
resolvedInput: v.record(v.string(), v.unknown()),
|
||||
output: v.record(v.string(), v.unknown()),
|
||||
stateChanges: v.record(v.string(), v.unknown()),
|
||||
});
|
||||
|
||||
const TracePageSchema = v.object({
|
||||
frames: v.array(TraceFrameSchema),
|
||||
traceStart: v.number(),
|
||||
traceLimit: v.number(),
|
||||
traceTruncated: v.boolean(),
|
||||
});
|
||||
|
||||
// Exported types
|
||||
export type ArtifactSummary = v.InferOutput<typeof ArtifactSummarySchema>;
|
||||
export type ArtifactDetail = v.InferOutput<typeof ArtifactDetailSchema>;
|
||||
export type DeploymentSummary = v.InferOutput<typeof DeploymentSummarySchema>;
|
||||
export type DeploymentDetail = v.InferOutput<typeof DeploymentDetailSchema>;
|
||||
export type DeploymentValidation = v.InferOutput<typeof DeploymentValidationSchema>;
|
||||
export type RunSummary = v.InferOutput<typeof RunSummarySchema>;
|
||||
export type RunDetail = v.InferOutput<typeof RunDetailSchema>;
|
||||
export type TraceFrame = v.InferOutput<typeof TraceFrameSchema>;
|
||||
export type TracePage = v.InferOutput<typeof TracePageSchema>;
|
||||
|
||||
// Exported decoders
|
||||
export const decodeArtifactList = (value: unknown): ArtifactList =>
|
||||
decode("ArtifactList", ArtifactListSchema, value);
|
||||
|
||||
export const decodeArtifactDetail = (value: unknown): ArtifactDetail =>
|
||||
decode("ArtifactDetail", ArtifactDetailSchema, value);
|
||||
|
||||
export const decodeDeploymentList = (value: unknown): DeploymentList =>
|
||||
decode("DeploymentList", DeploymentListSchema, value);
|
||||
|
||||
export const decodeDeploymentDetail = (value: unknown): DeploymentDetail =>
|
||||
decode("DeploymentDetail", DeploymentDetailSchema, value);
|
||||
|
||||
export const decodeDeploymentValidation = (
|
||||
value: unknown,
|
||||
): DeploymentValidation =>
|
||||
decode("DeploymentValidation", DeploymentValidationSchema, value);
|
||||
|
||||
export const decodeRunList = (value: unknown): RunList =>
|
||||
decode("RunList", RunListSchema, value);
|
||||
|
||||
export const decodeRunDetail = (value: unknown): RunDetail =>
|
||||
decode("RunDetail", RunDetailSchema, value);
|
||||
|
||||
export const decodeTracePage = (value: unknown): TracePage =>
|
||||
decode("TracePage", TracePageSchema, value);
|
||||
|
||||
// List wrapper types
|
||||
export type ArtifactList = v.InferOutput<typeof ArtifactListSchema>;
|
||||
export type DeploymentList = v.InferOutput<typeof DeploymentListSchema>;
|
||||
export type RunList = v.InferOutput<typeof RunListSchema>;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
lifecycleReducer,
|
||||
initialLifecycleState,
|
||||
type LifecycleState,
|
||||
type LifecycleAction,
|
||||
} from "./state.js";
|
||||
|
||||
describe("lifecycleReducer", () => {
|
||||
it("selectArtifact clears deployment and run selections", () => {
|
||||
const state: LifecycleState = {
|
||||
...initialLifecycleState,
|
||||
selectedArtifactId: "old@1",
|
||||
selectedDeploymentId: "old.default",
|
||||
selectedRunId: "run_1",
|
||||
deploymentDetail: { id: "old.default" } as LifecycleState["deploymentDetail"],
|
||||
runDetail: { runId: "run_1" } as LifecycleState["runDetail"],
|
||||
trace: { frames: [], traceStart: 0, traceLimit: 50, traceTruncated: false } as LifecycleState["trace"],
|
||||
};
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "selectArtifact",
|
||||
artifactId: "report@1",
|
||||
});
|
||||
|
||||
expect(result.selectedArtifactId).toBe("report@1");
|
||||
expect(result.selectedDeploymentId).toBeNull();
|
||||
expect(result.selectedRunId).toBeNull();
|
||||
expect(result.deploymentDetail).toBeNull();
|
||||
expect(result.runDetail).toBeNull();
|
||||
expect(result.trace).toBeNull();
|
||||
});
|
||||
|
||||
it("selectDeployment clears run selection", () => {
|
||||
const state: LifecycleState = {
|
||||
...initialLifecycleState,
|
||||
selectedArtifactId: "report@1",
|
||||
selectedDeploymentId: "old.default",
|
||||
selectedRunId: "run_1",
|
||||
runDetail: { runId: "run_1" } as LifecycleState["runDetail"],
|
||||
trace: { frames: [], traceStart: 0, traceLimit: 50, traceTruncated: false } as LifecycleState["trace"],
|
||||
};
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "selectDeployment",
|
||||
deploymentId: "report.default",
|
||||
});
|
||||
|
||||
expect(result.selectedDeploymentId).toBe("report.default");
|
||||
expect(result.selectedRunId).toBeNull();
|
||||
expect(result.runDetail).toBeNull();
|
||||
expect(result.trace).toBeNull();
|
||||
});
|
||||
|
||||
it("targetChanged resets to initial state", () => {
|
||||
const state: LifecycleState = {
|
||||
...initialLifecycleState,
|
||||
selectedArtifactId: "report@1",
|
||||
artifactList: { phase: "loaded", value: { items: [], total: 0, nextCursor: null } },
|
||||
};
|
||||
|
||||
const result = lifecycleReducer(state, { type: "targetChanged" });
|
||||
|
||||
expect(result).toEqual(initialLifecycleState);
|
||||
});
|
||||
|
||||
it("handles loading states", () => {
|
||||
const state = initialLifecycleState;
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "setArtifactListPhase",
|
||||
phase: "loading",
|
||||
});
|
||||
|
||||
expect(result.artifactList.phase).toBe("loading");
|
||||
});
|
||||
|
||||
it("handles loaded states", () => {
|
||||
const state = initialLifecycleState;
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "setArtifactListPhase",
|
||||
phase: "loaded",
|
||||
value: { items: [], total: 0, nextCursor: null },
|
||||
});
|
||||
|
||||
expect(result.artifactList.phase).toBe("loaded");
|
||||
});
|
||||
|
||||
it("handles error states", () => {
|
||||
const state = initialLifecycleState;
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "setArtifactListPhase",
|
||||
phase: "error",
|
||||
message: "failed to load",
|
||||
});
|
||||
|
||||
expect(result.artifactList.phase).toBe("error");
|
||||
});
|
||||
|
||||
it("appendArtifactList merges new items with existing", () => {
|
||||
const state: LifecycleState = {
|
||||
...initialLifecycleState,
|
||||
artifactList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{ key: "report@1", artifactId: "report", version: 1, kind: "workflow", displayName: "Report", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
|
||||
],
|
||||
total: 2,
|
||||
nextCursor: "cursor_1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "appendArtifactList",
|
||||
value: {
|
||||
items: [
|
||||
{ key: "summary@1", artifactId: "summary", version: 1, kind: "workflow", displayName: "Summary", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
|
||||
],
|
||||
total: 2,
|
||||
nextCursor: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.artifactList.phase !== "loaded") throw new Error("expected loaded");
|
||||
expect(result.artifactList.value.items).toHaveLength(2);
|
||||
expect(result.artifactList.value.items[0]!.artifactId).toBe("report");
|
||||
expect(result.artifactList.value.items[1]!.artifactId).toBe("summary");
|
||||
expect(result.artifactList.value.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("appendRunList merges new items with existing", () => {
|
||||
const state: LifecycleState = {
|
||||
...initialLifecycleState,
|
||||
runList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [
|
||||
{ runId: "run_1", deploymentId: "report.default", artifactId: "report", artifactVersion: 1, status: "interrupted", resumeReadiness: "ready", diagnosticCount: 0 },
|
||||
],
|
||||
total: 2,
|
||||
nextCursor: "cursor_1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = lifecycleReducer(state, {
|
||||
type: "appendRunList",
|
||||
value: {
|
||||
items: [
|
||||
{ runId: "run_2", deploymentId: "report.default", artifactId: "report", artifactVersion: 1, status: "completed", resumeReadiness: "none", diagnosticCount: 0 },
|
||||
],
|
||||
total: 2,
|
||||
nextCursor: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.runList.phase !== "loaded") throw new Error("expected loaded");
|
||||
expect(result.runList.value.items).toHaveLength(2);
|
||||
expect(result.runList.value.items[0]!.runId).toBe("run_1");
|
||||
expect(result.runList.value.items[1]!.runId).toBe("run_2");
|
||||
expect(result.runList.value.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("appendArtifactList initializes from idle state", () => {
|
||||
const result = lifecycleReducer(initialLifecycleState, {
|
||||
type: "appendArtifactList",
|
||||
value: {
|
||||
items: [
|
||||
{ key: "report@1", artifactId: "report", version: 1, kind: "workflow", displayName: "Report", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
|
||||
],
|
||||
total: 1,
|
||||
nextCursor: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.artifactList.phase !== "loaded") throw new Error("expected loaded");
|
||||
expect(result.artifactList.value.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import type {
|
||||
ArtifactList,
|
||||
ArtifactDetail,
|
||||
DeploymentList,
|
||||
DeploymentDetail,
|
||||
DeploymentValidation,
|
||||
RunList,
|
||||
RunDetail,
|
||||
TracePage,
|
||||
} from "./models.js";
|
||||
|
||||
export type LoadState<T> =
|
||||
| { readonly phase: "idle" }
|
||||
| { readonly phase: "loading"; readonly previous: T | null }
|
||||
| { readonly phase: "loaded"; readonly value: T }
|
||||
| { readonly phase: "error"; readonly message: string; readonly previous: T | null };
|
||||
|
||||
export type EvidenceRecord = {
|
||||
readonly id: string;
|
||||
readonly operation: string;
|
||||
readonly label: string;
|
||||
readonly equivalentCli: string;
|
||||
readonly request: unknown;
|
||||
readonly response: unknown;
|
||||
readonly durationMs: number;
|
||||
};
|
||||
|
||||
export type LifecycleError = {
|
||||
readonly operation: string;
|
||||
readonly message: string;
|
||||
readonly timestamp: number;
|
||||
};
|
||||
|
||||
export type LifecycleState = {
|
||||
readonly artifactList: LoadState<ArtifactList>;
|
||||
readonly deploymentList: LoadState<DeploymentList>;
|
||||
readonly runList: LoadState<RunList>;
|
||||
readonly selectedArtifactId: string | null;
|
||||
readonly artifactDetail: ArtifactDetail | null;
|
||||
readonly selectedDeploymentId: string | null;
|
||||
readonly deploymentDetail: DeploymentDetail | null;
|
||||
readonly deploymentValidation: DeploymentValidation | null;
|
||||
readonly selectedRunId: string | null;
|
||||
readonly runDetail: RunDetail | null;
|
||||
readonly trace: TracePage | null;
|
||||
readonly rawEvidence: ReadonlyArray<EvidenceRecord>;
|
||||
readonly errors: ReadonlyArray<LifecycleError>;
|
||||
};
|
||||
|
||||
export const initialLifecycleState: LifecycleState = {
|
||||
artifactList: { phase: "idle" },
|
||||
deploymentList: { phase: "idle" },
|
||||
runList: { phase: "idle" },
|
||||
selectedArtifactId: null,
|
||||
artifactDetail: null,
|
||||
selectedDeploymentId: null,
|
||||
deploymentDetail: null,
|
||||
deploymentValidation: null,
|
||||
selectedRunId: null,
|
||||
runDetail: null,
|
||||
trace: null,
|
||||
rawEvidence: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
export type LifecycleAction =
|
||||
| { readonly type: "targetChanged" }
|
||||
| { readonly type: "selectArtifact"; readonly artifactId: string | null }
|
||||
| { readonly type: "selectDeployment"; readonly deploymentId: string | null }
|
||||
| { readonly type: "selectRun"; readonly runId: string | null }
|
||||
| { readonly type: "setArtifactListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
|
||||
| { readonly type: "setArtifactListPhase"; readonly phase: "loaded"; readonly value: ArtifactList }
|
||||
| { readonly type: "setDeploymentListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
|
||||
| { readonly type: "setDeploymentListPhase"; readonly phase: "loaded"; readonly value: DeploymentList }
|
||||
| { readonly type: "setRunListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
|
||||
| { readonly type: "setRunListPhase"; readonly phase: "loaded"; readonly value: RunList }
|
||||
| { readonly type: "appendArtifactList"; readonly value: ArtifactList }
|
||||
| { readonly type: "appendRunList"; readonly value: RunList }
|
||||
| { readonly type: "setArtifactDetail"; readonly detail: ArtifactDetail | null }
|
||||
| { readonly type: "setDeploymentDetail"; readonly detail: DeploymentDetail | null }
|
||||
| { readonly type: "setDeploymentValidation"; readonly validation: DeploymentValidation | null }
|
||||
| { readonly type: "setRunDetail"; readonly detail: RunDetail | null }
|
||||
| { readonly type: "setTrace"; readonly trace: TracePage | null }
|
||||
| { readonly type: "setRawEvidence"; readonly evidence: ReadonlyArray<EvidenceRecord> }
|
||||
| { readonly type: "pushError"; readonly error: LifecycleError };
|
||||
|
||||
const setLoadPhase = <T>(
|
||||
current: LoadState<T>,
|
||||
action: { phase: string; value?: T; message?: string },
|
||||
): LoadState<T> => {
|
||||
switch (action.phase) {
|
||||
case "idle":
|
||||
return { phase: "idle" };
|
||||
case "loading":
|
||||
return { phase: "loading", previous: current.phase === "loaded" ? current.value : current.phase === "error" ? current.previous : null };
|
||||
case "loaded":
|
||||
return { phase: "loaded", value: action.value as T };
|
||||
case "error":
|
||||
return {
|
||||
phase: "error",
|
||||
message: action.message ?? "unknown error",
|
||||
previous: current.phase === "loaded" ? current.value : current.phase === "error" ? current.previous : null,
|
||||
};
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
};
|
||||
|
||||
export const lifecycleReducer = (
|
||||
state: LifecycleState,
|
||||
action: LifecycleAction,
|
||||
): LifecycleState => {
|
||||
switch (action.type) {
|
||||
case "targetChanged":
|
||||
return initialLifecycleState;
|
||||
|
||||
case "selectArtifact":
|
||||
return {
|
||||
...state,
|
||||
selectedArtifactId: action.artifactId,
|
||||
selectedDeploymentId: null,
|
||||
selectedRunId: null,
|
||||
artifactDetail: null,
|
||||
deploymentDetail: null,
|
||||
deploymentValidation: null,
|
||||
runDetail: null,
|
||||
trace: null,
|
||||
};
|
||||
|
||||
case "selectDeployment":
|
||||
return {
|
||||
...state,
|
||||
selectedDeploymentId: action.deploymentId,
|
||||
selectedRunId: null,
|
||||
deploymentDetail: null,
|
||||
deploymentValidation: null,
|
||||
runDetail: null,
|
||||
trace: null,
|
||||
};
|
||||
|
||||
case "selectRun":
|
||||
return {
|
||||
...state,
|
||||
selectedRunId: action.runId,
|
||||
runDetail: null,
|
||||
trace: null,
|
||||
};
|
||||
|
||||
case "setArtifactListPhase":
|
||||
return {
|
||||
...state,
|
||||
artifactList: setLoadPhase(state.artifactList, action),
|
||||
};
|
||||
|
||||
case "setDeploymentListPhase":
|
||||
return {
|
||||
...state,
|
||||
deploymentList: setLoadPhase(state.deploymentList, action),
|
||||
};
|
||||
|
||||
case "setRunListPhase":
|
||||
return {
|
||||
...state,
|
||||
runList: setLoadPhase(state.runList, action),
|
||||
};
|
||||
|
||||
case "appendArtifactList": {
|
||||
const previous = state.artifactList.phase === "loaded" ? state.artifactList.value : null;
|
||||
return {
|
||||
...state,
|
||||
artifactList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [...(previous?.items ?? []), ...action.value.items],
|
||||
nextCursor: action.value.nextCursor,
|
||||
total: action.value.total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "appendRunList": {
|
||||
const previous = state.runList.phase === "loaded" ? state.runList.value : null;
|
||||
return {
|
||||
...state,
|
||||
runList: {
|
||||
phase: "loaded",
|
||||
value: {
|
||||
items: [...(previous?.items ?? []), ...action.value.items],
|
||||
nextCursor: action.value.nextCursor,
|
||||
total: action.value.total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "setArtifactDetail":
|
||||
return {
|
||||
...state,
|
||||
artifactDetail: action.detail,
|
||||
};
|
||||
|
||||
case "setDeploymentDetail":
|
||||
return {
|
||||
...state,
|
||||
deploymentDetail: action.detail,
|
||||
};
|
||||
|
||||
case "setDeploymentValidation":
|
||||
return {
|
||||
...state,
|
||||
deploymentValidation: action.validation,
|
||||
};
|
||||
|
||||
case "setRunDetail":
|
||||
return {
|
||||
...state,
|
||||
runDetail: action.detail,
|
||||
};
|
||||
|
||||
case "setTrace":
|
||||
return {
|
||||
...state,
|
||||
trace: action.trace,
|
||||
};
|
||||
|
||||
case "setRawEvidence":
|
||||
return {
|
||||
...state,
|
||||
rawEvidence: action.evidence,
|
||||
};
|
||||
|
||||
case "pushError":
|
||||
return {
|
||||
...state,
|
||||
errors: [...state.errors, action.error],
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useLifecycleExplorer } from "./useLifecycleExplorer.js";
|
||||
|
||||
const mockCallOperation = vi.fn();
|
||||
vi.mock("../connection/api.js", () => ({
|
||||
callOperation: (...args: unknown[]) => mockCallOperation(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockCallOperation.mockReset();
|
||||
});
|
||||
|
||||
describe("useLifecycleExplorer", () => {
|
||||
it("loads artifact, deployment, and run lists on target change", async () => {
|
||||
mockCallOperation.mockResolvedValue({
|
||||
ok: true,
|
||||
operation: "workflow.artifacts.list",
|
||||
interpreted: { items: [], total: 0, nextCursor: null },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf artifact list",
|
||||
durationMs: 5,
|
||||
});
|
||||
|
||||
const recordEvidence = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
expect(mockCallOperation).toHaveBeenCalledWith(
|
||||
"workflow.artifacts.list",
|
||||
"http://127.0.0.1:8000/rpc",
|
||||
expect.objectContaining({ limit: 50 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects an artifact and requests inspect", async () => {
|
||||
mockCallOperation.mockImplementation(async (operation: string) => {
|
||||
if (operation === "workflow.artifacts.inspect") {
|
||||
return {
|
||||
ok: true,
|
||||
operation: "workflow.artifacts.inspect",
|
||||
interpreted: {
|
||||
artifactId: "report",
|
||||
version: 1,
|
||||
title: "Report",
|
||||
kind: "workflow",
|
||||
description: null,
|
||||
outcomes: ["ok"],
|
||||
plan: { nodes: [], edges: [] },
|
||||
requiredCapabilities: [],
|
||||
workflowDependencies: {},
|
||||
createdFromCatalogVersion: null,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf artifact inspect report --version 1",
|
||||
durationMs: 5,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
interpreted: {},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "",
|
||||
durationMs: 5,
|
||||
};
|
||||
});
|
||||
|
||||
const recordEvidence = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.selectArtifact("report@1");
|
||||
});
|
||||
|
||||
expect(mockCallOperation).toHaveBeenCalledWith(
|
||||
"workflow.artifacts.inspect",
|
||||
"http://127.0.0.1:8000/rpc",
|
||||
expect.objectContaining({ artifact_id: "report", version: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps deployment inspect and validation results from the same selection", async () => {
|
||||
mockCallOperation.mockImplementation(async (operation: string) => {
|
||||
if (operation === "workflow.deployments.inspect") {
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
interpreted: {
|
||||
id: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
bindings: [],
|
||||
driftPolicy: "block",
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf deploy inspect report.default",
|
||||
durationMs: 5,
|
||||
};
|
||||
}
|
||||
if (operation === "workflow.deployments.validate") {
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
interpreted: {
|
||||
deploymentId: "report.default",
|
||||
artifactId: "report",
|
||||
artifactVersion: 1,
|
||||
status: "runnable",
|
||||
diagnostics: [],
|
||||
nextActions: {
|
||||
canContinue: true,
|
||||
canSaveNow: null,
|
||||
recommendedNextTool: null,
|
||||
reason: "deployment is runnable",
|
||||
patchExamples: [],
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf deploy validate report.default",
|
||||
durationMs: 5,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
interpreted:
|
||||
operation === "workflow.deployments.list"
|
||||
? { items: [] }
|
||||
: { items: [], total: 0, nextCursor: null },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "",
|
||||
durationMs: 5,
|
||||
};
|
||||
});
|
||||
|
||||
const recordEvidence = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.selectDeployment("report.default");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.deploymentDetail?.id).toBe("report.default");
|
||||
expect(result.current.state.deploymentValidation?.status).toBe("runnable");
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale responses after target change", async () => {
|
||||
let callCount = 0;
|
||||
mockCallOperation.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
operation: "workflow.artifacts.list",
|
||||
interpreted: { items: [], total: 0, nextCursor: null },
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf artifact list",
|
||||
durationMs: 5,
|
||||
};
|
||||
});
|
||||
|
||||
const recordEvidence = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ target }) => useLifecycleExplorer(target, recordEvidence),
|
||||
{ initialProps: { target: "http://first-target/rpc" } },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
rerender({ target: "http://second-target/rpc" });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(mockCallOperation).toHaveBeenCalledWith(
|
||||
"workflow.artifacts.list",
|
||||
"http://second-target/rpc",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||
import { callOperation } from "../connection/api.js";
|
||||
import type { OperationName } from "../connection/contracts.js";
|
||||
import {
|
||||
decodeArtifactList,
|
||||
decodeArtifactDetail,
|
||||
decodeDeploymentList,
|
||||
decodeDeploymentDetail,
|
||||
decodeDeploymentValidation,
|
||||
decodeRunList,
|
||||
decodeRunDetail,
|
||||
decodeTracePage,
|
||||
} from "./models.js";
|
||||
import { lifecycleReducer, initialLifecycleState, type LifecycleState, type EvidenceRecord } from "./state.js";
|
||||
|
||||
export type LifecycleExplorerController = {
|
||||
readonly state: LifecycleState;
|
||||
readonly selectArtifact: (artifactId: string | null) => void;
|
||||
readonly selectDeployment: (deploymentId: string | null) => void;
|
||||
readonly selectRun: (runId: string | null) => void;
|
||||
readonly refresh: () => void;
|
||||
readonly loadMoreArtifacts: () => void;
|
||||
readonly loadMoreRuns: () => void;
|
||||
readonly loadTrace: (start: number, limit: number) => void;
|
||||
};
|
||||
|
||||
export const useLifecycleExplorer = (
|
||||
target: string | null,
|
||||
recordEvidence: (record: {
|
||||
id: string;
|
||||
operation: string;
|
||||
label: string;
|
||||
equivalentCli: string;
|
||||
request: unknown;
|
||||
response: unknown;
|
||||
durationMs: number;
|
||||
}) => void,
|
||||
): LifecycleExplorerController => {
|
||||
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
|
||||
const generationRef = useRef(0);
|
||||
const inspectGenerationRef = useRef(0);
|
||||
const rawEvidenceRef = useRef<ReadonlyArray<EvidenceRecord>>([]);
|
||||
const evidenceSeqRef = useRef(0);
|
||||
|
||||
const executeOperation = useCallback(
|
||||
async (
|
||||
operation: OperationName,
|
||||
params: unknown,
|
||||
generation: number,
|
||||
checkGenerationRef: React.MutableRefObject<number>,
|
||||
onSuccess: (interpreted: unknown) => void,
|
||||
) => {
|
||||
if (!target) return;
|
||||
try {
|
||||
const result = await callOperation(operation, target, params);
|
||||
if (generation !== checkGenerationRef.current) return;
|
||||
if (result.ok) {
|
||||
const seq = evidenceSeqRef.current++;
|
||||
const record: EvidenceRecord = {
|
||||
id: `${result.operation}-${seq}`,
|
||||
operation: result.operation,
|
||||
label: result.operation,
|
||||
equivalentCli: result.equivalentCli,
|
||||
request: result.exchange.request,
|
||||
response: result.exchange.response,
|
||||
durationMs: result.durationMs,
|
||||
};
|
||||
recordEvidence(record);
|
||||
rawEvidenceRef.current = [...rawEvidenceRef.current, record];
|
||||
dispatch({ type: "setRawEvidence", evidence: rawEvidenceRef.current });
|
||||
try {
|
||||
onSuccess(result.interpreted);
|
||||
} catch (decodeError) {
|
||||
dispatch({
|
||||
type: "pushError",
|
||||
error: {
|
||||
operation: result.operation,
|
||||
message: decodeError instanceof Error ? decodeError.message : String(decodeError),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
dispatch({
|
||||
type: "pushError",
|
||||
error: {
|
||||
operation,
|
||||
message: result.error.message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (rpcError) {
|
||||
dispatch({
|
||||
type: "pushError",
|
||||
error: {
|
||||
operation,
|
||||
message: rpcError instanceof Error ? rpcError.message : String(rpcError),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[target, recordEvidence],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
rawEvidenceRef.current = [];
|
||||
dispatch({ type: "targetChanged" });
|
||||
|
||||
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
||||
});
|
||||
|
||||
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
||||
});
|
||||
|
||||
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
||||
});
|
||||
}, [target, executeOperation]);
|
||||
|
||||
const selectArtifact = useCallback(
|
||||
(artifactId: string | null) => {
|
||||
dispatch({ type: "selectArtifact", artifactId });
|
||||
if (!artifactId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
const [id, version] = artifactId.split("@");
|
||||
executeOperation(
|
||||
"workflow.artifacts.inspect",
|
||||
{ artifact_id: id, version: Number(version) },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) });
|
||||
},
|
||||
);
|
||||
},
|
||||
[target, executeOperation],
|
||||
);
|
||||
|
||||
const selectDeployment = useCallback(
|
||||
(deploymentId: string | null) => {
|
||||
dispatch({ type: "selectDeployment", deploymentId });
|
||||
if (!deploymentId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
// Deployment selection fans out to inspect + validate. Both describe the
|
||||
// same selected deployment, so they must share one generation token.
|
||||
executeOperation(
|
||||
"workflow.deployments.inspect",
|
||||
{ deployment_id: deploymentId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) });
|
||||
},
|
||||
);
|
||||
executeOperation(
|
||||
"workflow.deployments.validate",
|
||||
{ deployment_id: deploymentId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) });
|
||||
},
|
||||
);
|
||||
},
|
||||
[target, executeOperation],
|
||||
);
|
||||
|
||||
const selectRun = useCallback(
|
||||
(runId: string | null) => {
|
||||
dispatch({ type: "selectRun", runId });
|
||||
if (!runId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.inspect",
|
||||
{ run_id: runId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setRunDetail", detail: decodeRunDetail(interpreted) });
|
||||
},
|
||||
);
|
||||
},
|
||||
[target, executeOperation],
|
||||
);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
||||
});
|
||||
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
||||
});
|
||||
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
||||
});
|
||||
}, [target, executeOperation]);
|
||||
|
||||
const loadMoreArtifacts = useCallback(() => {
|
||||
const current = state.artifactList;
|
||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
executeOperation(
|
||||
"workflow.artifacts.list",
|
||||
{ cursor: current.value.nextCursor, limit: 50 },
|
||||
generation,
|
||||
generationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
|
||||
},
|
||||
);
|
||||
}, [state.artifactList, target, executeOperation]);
|
||||
|
||||
const loadMoreRuns = useCallback(() => {
|
||||
const current = state.runList;
|
||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.list",
|
||||
{ cursor: current.value.nextCursor, limit: 50 },
|
||||
generation,
|
||||
generationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
|
||||
},
|
||||
);
|
||||
}, [state.runList, target, executeOperation]);
|
||||
|
||||
const loadTrace = useCallback(
|
||||
(start: number, limit: number) => {
|
||||
if (!state.selectedRunId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.trace",
|
||||
{ run_id: state.selectedRunId, trace_range: { start, limit } },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) });
|
||||
},
|
||||
);
|
||||
},
|
||||
[state.selectedRunId, target, executeOperation],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
selectArtifact,
|
||||
selectDeployment,
|
||||
selectRun,
|
||||
refresh,
|
||||
loadMoreArtifacts,
|
||||
loadMoreRuns,
|
||||
loadTrace,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user