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
+1 -4
View File
@@ -7,9 +7,6 @@ import { AppRoutes } from "./AppRoutes.js";
afterEach(() => cleanup());
describe("AppRoutes", () => {
// Task 5-7 replace pending leaves and restore the deferred ConsoleHome surfaces.
it.todo("restores source, demo, and lifecycle application coverage after Task 5-7");
it.each([
["/", "Discover capabilities"],
["/console", "Discover capabilities"],
@@ -70,7 +67,7 @@ describe("AppRoutes", () => {
await userEvent.click(screen.getByRole("link", { name: "Runs" }));
expect(await screen.findByRole("heading", { name: "Runs" })).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Runs", level: 1 })).toBeInTheDocument();
expect(screen.getByRole("navigation", { name: "Workflow lifecycle" })).toBeInTheDocument();
});
});
+7 -26
View File
@@ -1,10 +1,10 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ConsoleWorkspace } from "../workspace/ConsoleWorkspace.js";
import { useConsoleWorkspace } from "../workspace/context.js";
import { DiscoverRoute } from "../workspace/routes/DiscoverRoute.js";
import { DraftDetailRoute } from "../workspace/routes/DraftDetailRoute.js";
import { DraftIndexRoute } from "../workspace/routes/DraftIndexRoute.js";
import { LifecycleRoute } from "../workspace/routes/LifecycleRoute.js";
import { PresentationRoute } from "../presentation/PresentationRoute.js";
const PresenterRoute = lazy(() => import("../presentation/presenter/PresenterRoute.js").then((module) => ({
@@ -17,24 +17,6 @@ const PresenterRouteFallback = () => (
</main>
);
const WorkspaceRoutePending = ({ label }: { readonly label: string }) => {
const { connection } = useConsoleWorkspace();
const connected = connection.phase === "connected";
return (
<section className="workspace-route-pending">
<p className="workspace-route-pending__eyebrow">Workspace route</p>
<h1>{label}</h1>
<p>
{connected
? `${label} is ready for its workflow surface.`
: `${label} is unavailable until a workflow server is connected.`}
</p>
{!connected && <p>Connect a workflow server to view {label}.</p>}
</section>
);
};
export const AppRoutes = () => (
<Routes>
<Route path="/" element={<Navigate to="/console/discover" replace />} />
@@ -43,13 +25,12 @@ export const AppRoutes = () => (
<Route path="discover" element={<DiscoverRoute />} />
<Route path="drafts" element={<DraftIndexRoute />} />
<Route path="drafts/:workspaceId" element={<DraftDetailRoute />} />
<Route path="artifacts" element={<WorkspaceRoutePending label="Artifacts" />} />
<Route path="artifacts/:artifactId/:version" element={<WorkspaceRoutePending label="Artifact" />} />
<Route path="deployments" element={<WorkspaceRoutePending label="Deployments" />} />
<Route path="deployments/:deploymentId" element={<WorkspaceRoutePending label="Deployment" />} />
<Route path="runs" element={<WorkspaceRoutePending label="Runs" />} />
<Route path="runs/:runId" element={<WorkspaceRoutePending label="Run" />} />
<Route path="results" element={<WorkspaceRoutePending label="Results" />} />
<Route path="artifacts" element={<LifecycleRoute kind="artifact" />} />
<Route path="artifacts/:artifactId/:version" element={<LifecycleRoute kind="artifact" />} />
<Route path="deployments" element={<LifecycleRoute kind="deployment" />} />
<Route path="deployments/:deploymentId" element={<LifecycleRoute kind="deployment" />} />
<Route path="runs" element={<LifecycleRoute kind="run" />} />
<Route path="runs/:runId" element={<LifecycleRoute kind="run" />} />
</Route>
<Route path="/present" element={<PresentationRoute />} />
<Route path="/presenter" element={<Suspense fallback={<PresenterRouteFallback />}><PresenterRoute /></Suspense>} />
-184
View File
@@ -1,184 +0,0 @@
import { useReducer, useEffect, useCallback, useRef } from "react";
import {
connectionReducer,
initialState,
type EvidenceRecord,
type SourceRecord,
} from "./state.js";
import { connectToServer, callOperation } from "../connection/api.js";
import { ConnectionHeader } from "../components/ConnectionHeader.js";
import { SourceInventory } from "../components/SourceInventory.js";
import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
const parseSources = (
data: unknown,
): SourceRecord[] => {
if (!data || typeof data !== "object") return [];
const obj = data as Record<string, unknown>;
if (!Array.isArray(obj.sources)) return [];
return obj.sources.map((entry: unknown, i: number) => {
const s = entry as Record<string, unknown>;
const id = typeof s.id === "string" ? s.id : `source-${i}`;
const kind = typeof s.kind === "string" ? s.kind : "unknown";
const enabled = s.enabled !== false;
const description =
typeof s.description === "string" ? s.description : null;
const counts = (s.counts ?? {}) as Record<string, number>;
return {
id,
kind,
enabled,
description,
toolCount: typeof counts.tools === "number" ? counts.tools : 0,
nodeSpecCount: typeof counts.nodeSpecs === "number" ? counts.nodeSpecs : 0,
reducerCount: typeof counts.reducers === "number" ? counts.reducers : 0,
promptCount: typeof counts.prompts === "number" ? counts.prompts : 0,
resourceCount:
typeof counts.resources === "number" ? counts.resources : 0,
};
});
};
export const ConsoleHome = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
const connectGeneration = useRef(0);
const sourcesGeneration = useRef(0);
const connectedTarget = state.phase === "connected" ? state.connectedTarget : null;
const recordEvidence = useCallback(
(record: EvidenceRecord) => dispatch({ type: "evidence_recorded", record }),
[],
);
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const demoController = useDemoTimeline(connectedTarget, recordEvidence);
const loadSources = useCallback(
async (target: string) => {
const generation = ++sourcesGeneration.current;
dispatch({ type: "sources_loading" });
try {
const result = await callOperation(
"workflow.sources.list",
target,
{ limit: 50 },
);
if (sourcesGeneration.current !== generation) return;
if (result.ok) {
const sources = parseSources(result.interpreted);
dispatch({
type: "sources_loaded",
sources,
evidence: {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: result.equivalentCli,
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
},
});
} else {
dispatch({
type: "sources_error",
message: result.error.message,
evidence: {
id: `sources-${Date.now()}`,
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf source list --limit 50",
request: result.exchange.request,
response: result.exchange.response,
durationMs: 0,
},
});
}
} catch (e: unknown) {
if (sourcesGeneration.current !== generation) return;
dispatch({
type: "sources_error",
message: e instanceof Error ? e.message : "unknown error",
});
}
},
[],
);
useEffect(() => {
if (state.phase === "connected" && state.connectedTarget) {
void loadSources(state.connectedTarget);
}
}, [state.phase, state.connectedTarget, loadSources]);
const onSubmit = (target: string) => {
const generation = ++connectGeneration.current;
sourcesGeneration.current++;
dispatch({ type: "submit", target });
void connectToServer(target).then(
(response) => {
if (connectGeneration.current !== generation) return;
if (response.ok) {
dispatch({ type: "success", data: response });
dispatch({
type: "evidence_recorded",
record: {
id: `health-${Date.now()}`,
operation: "workflow.health",
label: "Health check",
equivalentCli: "uv run wf status",
request: response.exchange.request,
response: response.exchange.response,
durationMs: response.connection.durationMs,
},
});
} else {
dispatch({
type: "failure",
code: response.error.code,
message: response.error.message,
});
}
},
(e: unknown) => {
if (connectGeneration.current !== generation) return;
dispatch({
type: "failure",
code: errorCodeFromThrown(e),
message: e instanceof Error ? e.message : "unknown error",
});
},
);
};
return (
<div className="app-layout">
<ConnectionHeader
state={state}
onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/>
<LdaReportDemoPanel controller={demoController} />
<SourceInventory
sources={state.sources}
loading={state.sourcesLoading}
error={state.sourceError}
/>
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer" data-panel="lifecycle-explorer">
<LifecycleExplorer controller={lifecycleController} />
</section>
</div>
);
};
const errorCodeFromThrown = (error: unknown): string => {
if (!(error instanceof Error)) return "rpc_protocol_error";
return error.message.toLowerCase().includes("malformed")
? "malformed_response"
: "rpc_protocol_error";
};
+1 -57
View File
@@ -3,9 +3,7 @@ import {
connectionReducer,
initialState,
type EvidenceRecord,
type SourceRecord,
type ConnectionState,
type ConnectionAction,
STORAGE_KEY,
} from "./state.js";
@@ -33,20 +31,6 @@ const evidence: EvidenceRecord = {
durationMs: 12,
};
const sources: ReadonlyArray<SourceRecord> = [
{
id: "local.demo",
kind: "python",
enabled: true,
description: null,
toolCount: 1,
nodeSpecCount: 1,
reducerCount: 0,
promptCount: 0,
resourceCount: 0,
},
];
beforeEach(() => {
try {
sessionStorage.clear();
@@ -148,7 +132,6 @@ describe("success", () => {
expect(next.serverStatus).toBe("ok");
expect(next.storeRoot).toBe("/tmp/store");
expect(next.durationMs).toBe(10);
expect(next.sourcesLoading).toBe(false);
});
it("persists target to sessionStorage when available", () => {
@@ -279,46 +262,7 @@ describe("draft_changed", () => {
});
});
describe("source inventory", () => {
it("sets loading state for source refreshes", () => {
const state: ConnectionState = {
...initialState(),
sourceError: "old error",
};
const next = connectionReducer(state, { type: "sources_loading" });
expect(next.sourcesLoading).toBe(true);
expect(next.sourceError).toBeNull();
});
it("records loaded sources and evidence", () => {
const state = connectionReducer(initialState(), {
type: "sources_loading",
});
const next = connectionReducer(state, {
type: "sources_loaded",
sources,
evidence,
});
expect(next.sources).toBe(sources);
expect(next.sourcesLoading).toBe(false);
expect(next.sourceError).toBeNull();
expect(next.evidence).toEqual([evidence]);
});
it("records source errors and optional evidence", () => {
const state = connectionReducer(initialState(), {
type: "sources_loading",
});
const next = connectionReducer(state, {
type: "sources_error",
message: "source list failed",
evidence,
});
expect(next.sourcesLoading).toBe(false);
expect(next.sourceError).toBe("source list failed");
expect(next.evidence).toEqual([evidence]);
});
describe("evidence", () => {
it("appends protocol evidence records", () => {
const state = initialState();
const next = connectionReducer(state, {
-60
View File
@@ -20,18 +20,6 @@ export type EvidenceRecord = {
readonly durationMs: number;
};
export type SourceRecord = {
readonly id: string;
readonly kind: string;
readonly enabled: boolean;
readonly description: string | null;
readonly toolCount: number;
readonly nodeSpecCount: number;
readonly reducerCount: number;
readonly promptCount: number;
readonly resourceCount: number;
};
export type ConnectionState = {
readonly phase: ConnectionPhase;
readonly draftTarget: string;
@@ -41,9 +29,6 @@ export type ConnectionState = {
readonly durationMs: number | null;
readonly message: string | null;
readonly evidence: ReadonlyArray<EvidenceRecord>;
readonly sources: ReadonlyArray<SourceRecord>;
readonly sourcesLoading: boolean;
readonly sourceError: string | null;
};
export const STORAGE_KEY = "lda.workflowConsole.target";
@@ -70,9 +55,6 @@ export const initialState = (): ConnectionState => ({
durationMs: null,
message: null,
evidence: [],
sources: [],
sourcesLoading: false,
sourceError: null,
});
export type ConnectionAction =
@@ -80,17 +62,6 @@ export type ConnectionAction =
| { readonly type: "success"; readonly data: ConnectionSuccess }
| { readonly type: "failure"; readonly code: string; readonly message: string }
| { readonly type: "draft_changed"; readonly value: string }
| { readonly type: "sources_loading" }
| {
readonly type: "sources_loaded";
readonly sources: ReadonlyArray<SourceRecord>;
readonly evidence: EvidenceRecord;
}
| {
readonly type: "sources_error";
readonly message: string;
readonly evidence?: EvidenceRecord;
}
| { readonly type: "evidence_recorded"; readonly record: EvidenceRecord };
export const connectionReducer = (
@@ -104,9 +75,6 @@ export const connectionReducer = (
phase: "connecting",
draftTarget: action.target,
message: null,
sources: [],
sourceError: null,
sourcesLoading: false,
};
case "success": {
@@ -122,8 +90,6 @@ export const connectionReducer = (
storeRoot: action.data.connection.storeRoot,
durationMs: action.data.connection.durationMs,
message: null,
// The routed workspace does not fetch sources until a later task owns that surface.
sourcesLoading: false,
};
}
@@ -142,32 +108,6 @@ export const connectionReducer = (
draftTarget: action.value,
};
case "sources_loading":
return {
...state,
sourcesLoading: true,
sourceError: null,
};
case "sources_loaded":
return {
...state,
sources: action.sources,
sourcesLoading: false,
sourceError: null,
evidence: appendEvidence(state.evidence, action.evidence),
};
case "sources_error":
return {
...state,
sourcesLoading: false,
sourceError: action.message,
evidence: action.evidence
? appendEvidence(state.evidence, action.evidence)
: state.evidence,
};
case "evidence_recorded":
return {
...state,