refactor: route console lifecycle workspace

This commit is contained in:
lda
2026-08-04 23:14:31 +07:00 Verified
parent 7f01bd1915
commit f14d9f1278
22 changed files with 813 additions and 1487 deletions
@@ -23,7 +23,6 @@ const createMockController = (
selectedRunId: null,
runDetail: null,
trace: null,
rawEvidence: [],
errors: [],
...overrides,
},
@@ -37,6 +36,18 @@ const createMockController = (
});
describe("LifecycleExplorer", () => {
it("marks the route collection and omits the explorer-local raw evidence mode", () => {
const { container } = render(
<LifecycleExplorer controller={createMockController()} primaryKind="deployment" />,
);
expect(container.querySelector(".lifecycle-explorer")).toHaveAttribute(
"data-primary-lifecycle-kind",
"deployment",
);
expect(screen.queryByRole("button", { name: "Raw" })).toBeNull();
});
it("renders artifact buttons when loaded", () => {
const controller = createMockController({
artifactList: {
@@ -61,7 +72,7 @@ describe("LifecycleExplorer", () => {
},
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="artifact" />);
expect(screen.getByRole("option", { name: /Report version 1/i })).toBeVisible();
});
@@ -83,7 +94,7 @@ describe("LifecycleExplorer", () => {
},
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="deployment" />);
expect(screen.getByRole("option", { name: /report.default/i })).toBeVisible();
});
@@ -109,7 +120,7 @@ describe("LifecycleExplorer", () => {
},
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="run" />);
expect(screen.getByRole("option", { name: /run_1 interrupted/i })).toBeVisible();
});
@@ -137,7 +148,7 @@ describe("LifecycleExplorer", () => {
},
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="artifact" />);
fireEvent.click(screen.getAllByRole("option", { name: /Report version 1/i })[0]!);
expect(controller.selectArtifact).toHaveBeenCalledWith("report@1");
});
@@ -147,7 +158,7 @@ describe("LifecycleExplorer", () => {
artifactList: { phase: "loaded", value: { items: [], total: 0, nextCursor: null } },
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="artifact" />);
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
});
@@ -194,7 +205,7 @@ describe("LifecycleExplorer", () => {
},
});
render(<LifecycleExplorer controller={controller} />);
render(<LifecycleExplorer controller={controller} primaryKind="run" />);
expect(screen.getByText(/loading artifacts/i)).toBeVisible();
expect(screen.getByRole("alert")).toHaveTextContent(/could not load runs: network down/i);
@@ -10,9 +10,10 @@ import type { LoadState } from "./state.js";
type LifecycleExplorerProps = {
readonly controller: LifecycleExplorerController;
readonly primaryKind: "artifact" | "deployment" | "run";
};
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
type FocusMode = "lifecycle" | "graph" | "execution";
const loadedItems = <T,>(
state: LoadState<{ readonly items: ReadonlyArray<T> }>,
@@ -25,7 +26,7 @@ const listStatus = <T,>(label: string, state: LoadState<T>) => {
return null;
};
export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
export const LifecycleExplorer = ({ controller, primaryKind }: LifecycleExplorerProps) => {
const { state } = controller;
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
@@ -49,7 +50,7 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
}, [state.trace]);
return (
<div className="lifecycle-explorer">
<div className="lifecycle-explorer" data-primary-lifecycle-kind={primaryKind}>
<nav className="focus-nav" aria-label="Focus modes">
<button
onClick={() => setFocusMode("lifecycle")}
@@ -71,12 +72,6 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
>
Execution
</button>
<button
onClick={() => setFocusMode("raw")}
className={focusMode === "raw" ? "active" : ""}
>
Raw
</button>
</nav>
{focusMode === "lifecycle" && (
@@ -98,6 +93,7 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
selectedArtifactId={state.selectedArtifactId}
selectedDeploymentId={state.selectedDeploymentId}
selectedRunId={state.selectedRunId}
primaryKind={primaryKind}
onSelectArtifact={controller.selectArtifact}
onSelectDeployment={controller.selectDeployment}
onSelectRun={controller.selectRun}
@@ -136,36 +132,6 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
</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>
);
};
@@ -7,6 +7,7 @@ type RecordColumnsProps = {
readonly selectedArtifactId: string | null;
readonly selectedDeploymentId: string | null;
readonly selectedRunId: string | null;
readonly primaryKind: "artifact" | "deployment" | "run";
readonly onSelectArtifact: (artifactId: string | null) => void;
readonly onSelectDeployment: (deploymentId: string | null) => void;
readonly onSelectRun: (runId: string | null) => void;
@@ -23,6 +24,7 @@ export const RecordColumns = ({
selectedArtifactId,
selectedDeploymentId,
selectedRunId,
primaryKind,
onSelectArtifact,
onSelectDeployment,
onSelectRun,
@@ -32,7 +34,10 @@ export const RecordColumns = ({
hasMoreRuns,
}: RecordColumnsProps) => (
<div className="lifecycle-columns">
<div className="lifecycle-column">
<div
className={`lifecycle-column lifecycle-column--artifact${primaryKind === "artifact" ? " lifecycle-column--primary" : ""}`}
data-lifecycle-kind="artifact"
>
<h3>Artifacts</h3>
{artifacts.length === 0 ? (
<p className="empty-state">No artifacts</p>
@@ -58,7 +63,10 @@ export const RecordColumns = ({
</button>
)}
</div>
<div className="lifecycle-column">
<div
className={`lifecycle-column lifecycle-column--deployment${primaryKind === "deployment" ? " lifecycle-column--primary" : ""}`}
data-lifecycle-kind="deployment"
>
<h3>Deployments</h3>
{deployments.length === 0 ? (
<p className="empty-state">No deployments</p>
@@ -79,7 +87,10 @@ export const RecordColumns = ({
</ul>
)}
</div>
<div className="lifecycle-column">
<div
className={`lifecycle-column lifecycle-column--run${primaryKind === "run" ? " lifecycle-column--primary" : ""}`}
data-lifecycle-kind="run"
>
<h3>Runs</h3>
{runs.length === 0 ? (
<p className="empty-state">No runs</p>
-19
View File
@@ -15,16 +15,6 @@ export type LoadState<T> =
| { 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;
@@ -43,7 +33,6 @@ export type LifecycleState = {
readonly selectedRunId: string | null;
readonly runDetail: RunDetail | null;
readonly trace: TracePage | null;
readonly rawEvidence: ReadonlyArray<EvidenceRecord>;
readonly errors: ReadonlyArray<LifecycleError>;
};
@@ -59,7 +48,6 @@ export const initialLifecycleState: LifecycleState = {
selectedRunId: null,
runDetail: null,
trace: null,
rawEvidence: [],
errors: [],
};
@@ -81,7 +69,6 @@ export type LifecycleAction =
| { 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>(
@@ -224,12 +211,6 @@ export const lifecycleReducer = (
trace: action.trace,
};
case "setRawEvidence":
return {
...state,
rawEvidence: action.evidence,
};
case "pushError":
return {
...state,
@@ -1,316 +1,231 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
ArtifactClient,
DeploymentClient,
LifecycleClients,
RunClient,
} from "../workspace/domain/lifecycle-clients.js";
import { useLifecycleExplorer } from "./useLifecycleExplorer.js";
import type {
ArtifactDetail,
ArtifactList,
DeploymentDetail,
DeploymentList,
DeploymentValidation,
RunDetail,
RunList,
} from "./models.js";
const mockCallOperation = vi.fn();
vi.mock("../connection/api.js", () => ({
callOperation: (...args: unknown[]) => mockCallOperation(...args),
}));
const artifactList: ArtifactList = { items: [], total: 0, nextCursor: null };
const deploymentList: DeploymentList = { items: [] };
const runList: RunList = { items: [], total: 0, nextCursor: null };
const artifactDetail = {
artifactId: "report",
version: 2,
title: "Report",
kind: "workflow",
description: null,
outcomes: ["ok"],
plan: { nodes: [], edges: [] },
requiredCapabilities: [],
workflowDependencies: {},
createdFromCatalogVersion: null,
} satisfies ArtifactDetail;
const deploymentDetail = {
id: "report.default",
artifactId: "report",
artifactVersion: 2,
bindings: [],
driftPolicy: "block",
} satisfies DeploymentDetail;
const deploymentValidation = {
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 2,
status: "runnable",
diagnostics: [],
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: null,
reason: "ready",
patchExamples: [],
warnings: [],
},
} satisfies DeploymentValidation;
const runDetail = {
runId: "run_123",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 2,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "ok",
error: null,
output: {},
diagnostics: [],
traceCount: 0,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "done",
patchExamples: [],
warnings: [],
},
} satisfies RunDetail;
beforeEach(() => {
mockCallOperation.mockReset();
const makeClients = (overrides: Partial<{
artifacts: Partial<ArtifactClient>;
deployments: Partial<DeploymentClient>;
runs: Partial<RunClient>;
}> = {}): LifecycleClients => ({
artifacts: {
list: vi.fn().mockResolvedValue(artifactList),
inspect: vi.fn().mockResolvedValue(artifactDetail),
...overrides.artifacts,
},
deployments: {
list: vi.fn().mockResolvedValue(deploymentList),
inspect: vi.fn().mockResolvedValue(deploymentDetail),
validate: vi.fn().mockResolvedValue(deploymentValidation),
...overrides.deployments,
},
runs: {
list: vi.fn().mockResolvedValue(runList),
inspect: vi.fn().mockResolvedValue(runDetail),
trace: vi.fn().mockResolvedValue({
frames: [],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
}),
...overrides.runs,
},
});
beforeEach(() => vi.restoreAllMocks());
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,
it("loads all lifecycle collections through the Task 3 clients", async () => {
const clients = makeClients();
renderHook(() => useLifecycleExplorer(clients));
await waitFor(() => {
expect(clients.artifacts.list).toHaveBeenCalledWith({ limit: 50 });
expect(clients.deployments.list).toHaveBeenCalledWith();
expect(clients.runs.list).toHaveBeenCalledWith({ limit: 50 });
});
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,
};
});
it("uses domain methods for artifact, deployment, and run inspection", async () => {
const clients = makeClients();
const { result } = renderHook(() => useLifecycleExplorer(clients));
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 () => {
act(() => {
result.current.selectArtifact("report@2");
result.current.selectDeployment("report.default");
result.current.selectRun("run_123");
});
await waitFor(() => {
expect(result.current.state.deploymentDetail?.id).toBe("report.default");
expect(result.current.state.deploymentValidation?.status).toBe("runnable");
expect(clients.artifacts.inspect).toHaveBeenCalledWith("report", 2);
expect(clients.deployments.inspect).toHaveBeenCalledWith("report.default");
expect(clients.deployments.validate).toHaveBeenCalledWith("report.default");
expect(clients.runs.inspect).toHaveBeenCalledWith("run_123");
});
});
it("keeps deployment validation when a run selection happens before validation returns", async () => {
let releaseValidation: (() => void) | null = null;
const validationGate = new Promise<void>((resolve) => {
releaseValidation = resolve;
it("keeps a late response from the previous client generation out of state", async () => {
let releaseFirst!: (value: ArtifactList) => void;
const firstList = new Promise<ArtifactList>((resolve) => {
releaseFirst = resolve;
});
mockCallOperation.mockImplementation(async (operation: string) => {
if (operation === "workflow.deployments.validate") {
await validationGate;
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: [],
const first = makeClients({ artifacts: { list: vi.fn().mockReturnValue(firstList) } });
const second = makeClients({
artifacts: {
list: vi.fn().mockResolvedValue({
items: [
{
key: "new@1",
artifactId: "new",
version: 1,
kind: "workflow",
displayName: "New",
description: null,
outcomes: [],
requiredSources: [],
diagnosticCount: 0,
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy validate report.default",
durationMs: 5,
};
}
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.runs.inspect") {
return {
ok: true,
operation,
interpreted: {
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "ok",
error: null,
output: {},
diagnostics: [],
traceCount: 0,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "done",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run inspect run_1",
durationMs: 5,
};
}
return {
ok: true,
operation,
interpreted:
operation === "workflow.deployments.list"
? { items: [] }
: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "",
durationMs: 5,
};
],
total: 1,
nextCursor: null,
}),
},
});
const recordEvidence = vi.fn();
const { result } = renderHook(() =>
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
);
await act(async () => {
result.current.selectDeployment("report.default");
result.current.selectRun("run_1");
});
await act(async () => {
releaseValidation?.();
await validationGate;
});
await waitFor(() => {
expect(result.current.state.runDetail?.runId).toBe("run_1");
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" } },
({ clients: currentClients }) => useLifecycleExplorer(currentClients),
{ initialProps: { clients: first } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
rerender({ clients: second });
await waitFor(() => expect(result.current.state.artifactList.phase).toBe("loaded"));
releaseFirst(artifactList);
await act(async () => await firstList);
expect(result.current.state.artifactList).toMatchObject({
phase: "loaded",
value: { items: [{ key: "new@1" }] },
});
});
rerender({ target: "http://second-target/rpc" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
it("rejects a late artifact inspect after the URL-owned id changes", async () => {
let releaseFirst!: (value: ArtifactDetail) => void;
const firstInspect = new Promise<ArtifactDetail>((resolve) => {
releaseFirst = resolve;
});
expect(mockCallOperation).toHaveBeenCalledWith(
"workflow.artifacts.list",
"http://second-target/rpc",
expect.anything(),
const inspect = vi.fn((_: string, version: number) =>
version === 1 ? firstInspect : Promise.resolve(artifactDetail),
);
const clients = makeClients({ artifacts: { inspect } });
const { result } = renderHook(() => useLifecycleExplorer(clients));
act(() => result.current.selectArtifact("report@1"));
act(() => result.current.selectArtifact("report@2"));
await waitFor(() => expect(result.current.state.artifactDetail?.version).toBe(2));
releaseFirst({ ...artifactDetail, version: 1 });
await act(async () => await firstInspect);
expect(result.current.state.selectedArtifactId).toBe("report@2");
expect(result.current.state.artifactDetail?.version).toBe(2);
});
it("clears lifecycle state when the client bundle is disconnected", async () => {
const clients = makeClients();
const { result, rerender } = renderHook(
({ currentClients }) => useLifecycleExplorer(currentClients),
{ initialProps: { currentClients: clients as LifecycleClients | null } },
);
await waitFor(() => expect(result.current.state.artifactList.phase).toBe("loaded"));
rerender({ currentClients: null });
expect(result.current.state).toEqual({
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,
errors: [],
});
});
});
@@ -1,17 +1,7 @@
import { useReducer, useEffect, useRef, useCallback, type MutableRefObject } 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";
import { useCallback, useEffect, useReducer, useRef, type MutableRefObject } from "react";
import { ConsoleClientError } from "../workspace/domain/errors.js";
import type { LifecycleClients } from "../workspace/domain/lifecycle-clients.js";
import { lifecycleReducer, initialLifecycleState, type LifecycleState } from "./state.js";
export type LifecycleExplorerController = {
readonly state: LifecycleState;
@@ -24,85 +14,47 @@ export type LifecycleExplorerController = {
readonly loadTrace: (start: number, limit: number) => void;
};
type ReadFailure = (message: string, operation: string) => void;
const readErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const readErrorOperation = (error: unknown): string =>
error instanceof ConsoleClientError ? error.operation : "lifecycle";
export const useLifecycleExplorer = (
target: string | null,
recordEvidence: (record: EvidenceRecord) => void,
clients: LifecycleClients | null,
): LifecycleExplorerController => {
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
const generationRef = useRef(0);
const artifactGenerationRef = useRef(0);
const deploymentGenerationRef = useRef(0);
const runGenerationRef = useRef(0);
const rawEvidenceRef = useRef<ReadonlyArray<EvidenceRecord>>([]);
const evidenceSeqRef = useRef(0);
const executeOperation = useCallback(
async (
operation: OperationName,
params: unknown,
const executeRead = useCallback(
async <T>(
read: () => Promise<T>,
generation: number,
checkGenerationRef: MutableRefObject<number>,
onSuccess: (interpreted: unknown) => void,
onFailure?: (message: string) => void,
) => {
if (!target) return;
targetGeneration: number,
onSuccess: (value: T) => void,
onFailure?: ReadFailure,
): Promise<void> => {
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 {
onFailure?.(result.error.message);
const seq = evidenceSeqRef.current++;
const record: EvidenceRecord = {
id: `${operation}-${seq}`,
operation,
label: `${operation} failed`,
equivalentCli: "unavailable: operation failed before CLI metadata",
request: result.exchange.request,
response: result.exchange.response,
durationMs: 0,
};
recordEvidence(record);
rawEvidenceRef.current = [...rawEvidenceRef.current, record];
dispatch({ type: "setRawEvidence", evidence: rawEvidenceRef.current });
dispatch({
type: "pushError",
error: {
operation,
message: result.error.message,
timestamp: Date.now(),
},
});
}
} catch (rpcError) {
if (generation !== checkGenerationRef.current) return;
const message = rpcError instanceof Error ? rpcError.message : String(rpcError);
onFailure?.(message);
const value = await read();
if (
targetGeneration !== generationRef.current ||
generation !== checkGenerationRef.current
) return;
onSuccess(value);
} catch (error) {
if (
targetGeneration !== generationRef.current ||
generation !== checkGenerationRef.current
) return;
const message = readErrorMessage(error);
const operation = readErrorOperation(error);
onFailure?.(message, operation);
dispatch({
type: "pushError",
error: {
@@ -113,199 +65,198 @@ export const useLifecycleExplorer = (
});
}
},
[target, recordEvidence],
[],
);
const startCollectionReads = useCallback(
(
artifactGeneration: number,
deploymentGeneration: number,
runGeneration: number,
targetGeneration: number,
): void => {
if (!clients) return;
dispatch({ type: "setArtifactListPhase", phase: "loading" });
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
dispatch({ type: "setRunListPhase", phase: "loading" });
void executeRead(
() => clients.artifacts.list({ limit: 50 }),
artifactGeneration,
artifactGenerationRef,
targetGeneration,
(value) => dispatch({ type: "setArtifactListPhase", phase: "loaded", value }),
(message) => dispatch({ type: "setArtifactListPhase", phase: "error", message }),
);
void executeRead(
() => clients.deployments.list(),
deploymentGeneration,
deploymentGenerationRef,
targetGeneration,
(value) => dispatch({ type: "setDeploymentListPhase", phase: "loaded", value }),
(message) => dispatch({ type: "setDeploymentListPhase", phase: "error", message }),
);
void executeRead(
() => clients.runs.list({ limit: 50 }),
runGeneration,
runGenerationRef,
targetGeneration,
(value) => dispatch({ type: "setRunListPhase", phase: "loaded", value }),
(message) => dispatch({ type: "setRunListPhase", phase: "error", message }),
);
},
[clients, executeRead],
);
useEffect(() => {
if (!target) return;
generationRef.current++;
artifactGenerationRef.current++;
deploymentGenerationRef.current++;
runGenerationRef.current++;
const generation = generationRef.current;
rawEvidenceRef.current = [];
const targetGeneration = ++generationRef.current;
const artifactGeneration = ++artifactGenerationRef.current;
const deploymentGeneration = ++deploymentGenerationRef.current;
const runGeneration = ++runGenerationRef.current;
dispatch({ type: "targetChanged" });
dispatch({ type: "setArtifactListPhase", phase: "loading" });
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
dispatch({ type: "setRunListPhase", phase: "loading" });
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
}, (message) => {
dispatch({ type: "setArtifactListPhase", phase: "error", message });
});
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
}, (message) => {
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
});
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
});
}, [target, executeOperation]);
startCollectionReads(
artifactGeneration,
deploymentGeneration,
runGeneration,
targetGeneration,
);
}, [startCollectionReads]);
const selectArtifact = useCallback(
(artifactId: string | null) => {
dispatch({ type: "selectArtifact", artifactId });
if (!artifactId || !target) return;
(artifactKey: string | null): void => {
dispatch({ type: "selectArtifact", artifactId: artifactKey });
if (!artifactKey || !clients) return;
artifactGenerationRef.current++;
const generation = artifactGenerationRef.current;
const [id, version] = artifactId.split("@");
executeOperation(
"workflow.artifacts.inspect",
{ artifact_id: id, version: Number(version) },
const separator = artifactKey.lastIndexOf("@");
const artifactId = artifactKey.slice(0, separator);
const version = Number(artifactKey.slice(separator + 1));
void executeRead(
() => clients.artifacts.inspect(artifactId, version),
generation,
artifactGenerationRef,
(interpreted) => {
dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) });
},
generationRef.current,
(value) => dispatch({ type: "setArtifactDetail", detail: value }),
);
},
[target, executeOperation],
[clients, executeRead],
);
const selectDeployment = useCallback(
(deploymentId: string | null) => {
(deploymentId: string | null): void => {
dispatch({ type: "selectDeployment", deploymentId });
if (!deploymentId || !target) return;
if (!deploymentId || !clients) return;
deploymentGenerationRef.current++;
const generation = deploymentGenerationRef.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 },
const targetGeneration = generationRef.current;
// Inspection and validation describe one URL-owned deployment selection.
void executeRead(
() => clients.deployments.inspect(deploymentId),
generation,
deploymentGenerationRef,
(interpreted) => {
dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) });
},
targetGeneration,
(value) => dispatch({ type: "setDeploymentDetail", detail: value }),
);
executeOperation(
"workflow.deployments.validate",
{ deployment_id: deploymentId },
void executeRead(
() => clients.deployments.validate(deploymentId),
generation,
deploymentGenerationRef,
(interpreted) => {
dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) });
},
targetGeneration,
(value) => dispatch({ type: "setDeploymentValidation", validation: value }),
);
},
[target, executeOperation],
[clients, executeRead],
);
const selectRun = useCallback(
(runId: string | null) => {
(runId: string | null): void => {
dispatch({ type: "selectRun", runId });
if (!runId || !target) return;
if (!runId || !clients) return;
runGenerationRef.current++;
const generation = runGenerationRef.current;
executeOperation(
"workflow.runs.inspect",
{ run_id: runId },
const targetGeneration = generationRef.current;
void executeRead(
() => clients.runs.inspect(runId),
generation,
runGenerationRef,
(interpreted) => {
const detail = decodeRunDetail(interpreted);
dispatch({ type: "setRunDetail", detail });
if (detail.traceCount > 0) {
executeOperation(
"workflow.runs.trace",
{ run_id: runId, trace_range: { start: 0, limit: 50 } },
targetGeneration,
(value) => {
dispatch({ type: "setRunDetail", detail: value });
if (value.traceCount > 0) {
void executeRead(
() => clients.runs.trace(runId, 0, 50),
generation,
runGenerationRef,
(traceInterpreted) => {
dispatch({ type: "setTrace", trace: decodeTracePage(traceInterpreted) });
},
targetGeneration,
(trace) => dispatch({ type: "setTrace", trace }),
);
}
},
);
},
[target, executeOperation],
[clients, executeRead],
);
const refresh = useCallback(() => {
if (!target) return;
const refresh = useCallback((): void => {
if (!clients) return;
generationRef.current++;
artifactGenerationRef.current++;
deploymentGenerationRef.current++;
runGenerationRef.current++;
const artifactGeneration = artifactGenerationRef.current;
const deploymentGeneration = deploymentGenerationRef.current;
const runGeneration = runGenerationRef.current;
dispatch({ type: "setArtifactListPhase", phase: "loading" });
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
dispatch({ type: "setRunListPhase", phase: "loading" });
executeOperation("workflow.artifacts.list", { limit: 50 }, artifactGeneration, artifactGenerationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
}, (message) => {
dispatch({ type: "setArtifactListPhase", phase: "error", message });
});
executeOperation("workflow.deployments.list", {}, deploymentGeneration, deploymentGenerationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
}, (message) => {
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
});
executeOperation("workflow.runs.list", { limit: 50 }, runGeneration, runGenerationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
}, (message) => {
dispatch({ type: "setRunListPhase", phase: "error", message });
});
}, [target, executeOperation]);
startCollectionReads(
artifactGenerationRef.current,
deploymentGenerationRef.current,
runGenerationRef.current,
generationRef.current,
);
}, [clients, startCollectionReads]);
const loadMoreArtifacts = useCallback(() => {
const loadMoreArtifacts = useCallback((): void => {
const current = state.artifactList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
if (current.phase !== "loaded" || !current.value.nextCursor || !clients) return;
const cursor = current.value.nextCursor;
artifactGenerationRef.current++;
const generation = artifactGenerationRef.current;
executeOperation(
"workflow.artifacts.list",
{ cursor: current.value.nextCursor, limit: 50 },
void executeRead(
() => clients.artifacts.list({ cursor, limit: 50 }),
generation,
artifactGenerationRef,
(interpreted) => {
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
},
generationRef.current,
(value) => dispatch({ type: "appendArtifactList", value }),
);
}, [state.artifactList, target, executeOperation]);
}, [clients, executeRead, state.artifactList]);
const loadMoreRuns = useCallback(() => {
const loadMoreRuns = useCallback((): void => {
const current = state.runList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
if (current.phase !== "loaded" || !current.value.nextCursor || !clients) return;
const cursor = current.value.nextCursor;
runGenerationRef.current++;
const generation = runGenerationRef.current;
executeOperation(
"workflow.runs.list",
{ cursor: current.value.nextCursor, limit: 50 },
void executeRead(
() => clients.runs.list({ cursor, limit: 50 }),
generation,
runGenerationRef,
(interpreted) => {
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
},
generationRef.current,
(value) => dispatch({ type: "appendRunList", value }),
);
}, [state.runList, target, executeOperation]);
}, [clients, executeRead, state.runList]);
const loadTrace = useCallback(
(start: number, limit: number) => {
if (!state.selectedRunId || !target) return;
(start: number, limit: number): void => {
if (!state.selectedRunId || !clients) return;
runGenerationRef.current++;
const generation = runGenerationRef.current;
executeOperation(
"workflow.runs.trace",
{ run_id: state.selectedRunId, trace_range: { start, limit } },
const targetGeneration = generationRef.current;
const runId = state.selectedRunId;
void executeRead(
() => clients.runs.trace(runId, start, limit),
generation,
runGenerationRef,
(interpreted) => {
dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) });
},
targetGeneration,
(value) => dispatch({ type: "setTrace", trace: value }),
);
},
[state.selectedRunId, target, executeOperation],
[clients, executeRead, state.selectedRunId],
);
return {