refactor: route console lifecycle workspace
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>} />
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user