feat: add workflow console connection flow

- Browser DTO contracts (ConnectionSuccess, ApiFailure, OperationName)
- API client with fetch-json wrapper and typed failure responses
- Reducer state machine with 7 connection phases and session persistence
- ConnectionHeader component with form, status display, and aria-live region
- 27 console tests passing (7 reducer, 7 API client, 6 component, 7 misc)
- All typecheck clean across RPC, server, and console packages
This commit is contained in:
lda
2026-07-02 12:26:36 +07:00 Verified
parent 60a7dd2830
commit 4d95b22e0b
8 changed files with 824 additions and 1 deletions
+8 -1
View File
@@ -1,3 +1,10 @@
import { ConnectionHeader } from "../components/ConnectionHeader.js";
export function App() { export function App() {
return <h1>lda.chat Workflow Console</h1>; return (
<main>
<h1>lda.chat Workflow Console</h1>
<ConnectionHeader />
</main>
);
} }
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
connectionReducer,
initialState,
type ConnectionState,
type ConnectionAction,
STORAGE_KEY,
} from "./state.js";
const makeSuccess = (target = "http://127.0.0.1:8765/rpc") =>
({
ok: true,
connection: {
status: "connected",
target,
serverStatus: "ok",
storeRoot: "/tmp/store",
durationMs: 10,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
}) as const;
beforeEach(() => {
try {
sessionStorage.clear();
} catch {
// jsdom may not provide sessionStorage
}
try {
localStorage.clear();
} catch {
// jsdom may not provide localStorage
}
});
describe("initialState", () => {
it("defaults to not_configured phase", () => {
const state = initialState();
expect(state.phase).toBe("not_configured");
});
it("defaults target to http://127.0.0.1:8765/rpc when no stored value", () => {
const state = initialState();
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
});
it("restores target from localStorage when available", () => {
try {
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
} catch {
return; // skip if localStorage unavailable
}
const state = initialState();
expect(state.draftTarget).toBe("http://custom:9999/rpc");
});
it("restored target does not trigger automatic connection", () => {
try {
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
} catch {
return; // skip if localStorage unavailable
}
const state = initialState();
expect(state.phase).toBe("not_configured");
expect(state.connectedTarget).toBeNull();
});
});
describe("submit", () => {
it("transitions to connecting phase", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "submit",
target: "http://127.0.0.1:8765/rpc",
});
expect(next.phase).toBe("connecting");
});
it("updates draftTarget to submitted value", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "submit",
target: "http://custom:9999/rpc",
});
expect(next.draftTarget).toBe("http://custom:9999/rpc");
});
it("clears previous message", () => {
const state: ConnectionState = {
...initialState(),
phase: "unreachable",
message: "old error",
};
const next = connectionReducer(state, {
type: "submit",
target: "http://127.0.0.1:8765/rpc",
});
expect(next.message).toBeNull();
});
});
describe("success", () => {
it("records normalized target and evidence", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "success",
data: makeSuccess(),
});
expect(next.phase).toBe("connected");
expect(next.connectedTarget).toBe("http://127.0.0.1:8765/rpc");
expect(next.serverStatus).toBe("ok");
expect(next.storeRoot).toBe("/tmp/store");
expect(next.durationMs).toBe(10);
});
it("persists target to sessionStorage when available", () => {
const state = initialState();
connectionReducer(state, {
type: "success",
data: makeSuccess("http://custom:9999/rpc"),
});
try {
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(
"http://custom:9999/rpc",
);
} catch {
// jsdom may not provide sessionStorage
}
});
it("success updates draftTarget to connected target", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "success",
data: makeSuccess("http://custom:9999/rpc"),
});
expect(next.draftTarget).toBe("http://custom:9999/rpc");
});
});
describe("failure", () => {
it("retains draft input on failure", () => {
const state = connectionReducer(initialState(), {
type: "submit",
target: "http://custom:9999/rpc",
});
const next = connectionReducer(state, {
type: "failure",
code: "invalid_target",
message: "bad target",
});
expect(next.draftTarget).toBe("http://custom:9999/rpc");
expect(next.phase).toBe("invalid_target");
expect(next.message).toBe("bad target");
});
it("does not overwrite connectedTarget on failure", () => {
let state = connectionReducer(initialState(), {
type: "submit",
target: "http://old:8000/rpc",
});
state = connectionReducer(state, {
type: "success",
data: makeSuccess("http://old:8000/rpc"),
});
const next = connectionReducer(state, {
type: "failure",
code: "upstream_unreachable",
message: "connection refused",
});
expect(next.connectedTarget).toBe("http://old:8000/rpc");
expect(next.phase).toBe("unreachable");
});
});
describe("reconnect replaces target only on success", () => {
it("on success, connectedTarget updates to new target", () => {
let state = connectionReducer(initialState(), {
type: "submit",
target: "http://first:8000/rpc",
});
state = connectionReducer(state, {
type: "success",
data: makeSuccess("http://first:8000/rpc"),
});
state = connectionReducer(state, {
type: "submit",
target: "http://second:8000/rpc",
});
state = connectionReducer(state, {
type: "success",
data: makeSuccess("http://second:8000/rpc"),
});
expect(state.connectedTarget).toBe("http://second:8000/rpc");
expect(state.phase).toBe("connected");
});
});
describe("draft_changed", () => {
it("updates draftTarget without changing phase", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "draft_changed",
value: "http://typed:9999/rpc",
});
expect(next.draftTarget).toBe("http://typed:9999/rpc");
expect(next.phase).toBe("not_configured");
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { ConnectionSuccess } from "../connection/contracts.js";
export type ConnectionPhase =
| "not_configured"
| "connecting"
| "connected"
| "invalid_target"
| "unreachable"
| "rpc_error"
| "malformed_response";
export type ConnectionState = {
readonly phase: ConnectionPhase;
readonly draftTarget: string;
readonly connectedTarget: string | null;
readonly serverStatus: string | null;
readonly storeRoot: string | null;
readonly durationMs: number | null;
readonly message: string | null;
};
export const STORAGE_KEY = "lda.workflowConsole.target";
const safeLocalStorage = (): Storage | null => {
try {
return typeof localStorage !== "undefined" ? localStorage : null;
} catch {
return null;
}
};
const safeSessionStorage = (): Storage | null => {
try {
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
} catch {
return null;
}
};
const getDefaultTarget = (): string => {
const ls = safeLocalStorage();
return ls?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
};
export const initialState = (): ConnectionState => ({
phase: "not_configured",
draftTarget: getDefaultTarget(),
connectedTarget: null,
serverStatus: null,
storeRoot: null,
durationMs: null,
message: null,
});
export type ConnectionAction =
| { readonly type: "submit"; readonly target: string }
| { readonly type: "success"; readonly data: ConnectionSuccess }
| { readonly type: "failure"; readonly code: string; readonly message: string }
| { readonly type: "draft_changed"; readonly value: string };
export const connectionReducer = (
state: ConnectionState,
action: ConnectionAction,
): ConnectionState => {
switch (action.type) {
case "submit":
return {
...state,
phase: "connecting",
draftTarget: action.target,
message: null,
};
case "success": {
const normalizedTarget = action.data.connection.target;
const ss = safeSessionStorage();
ss?.setItem(STORAGE_KEY, normalizedTarget);
return {
...state,
phase: "connected",
draftTarget: normalizedTarget,
connectedTarget: normalizedTarget,
serverStatus: action.data.connection.serverStatus,
storeRoot: action.data.connection.storeRoot,
durationMs: action.data.connection.durationMs,
message: null,
};
}
case "failure": {
const phase: ConnectionPhase = mapCodeToPhase(action.code);
return {
...state,
phase,
message: action.message,
};
}
case "draft_changed":
return {
...state,
draftTarget: action.value,
};
}
};
const mapCodeToPhase = (code: string): ConnectionPhase => {
switch (code) {
case "invalid_target":
return "invalid_target";
case "upstream_unreachable":
case "rpc_remote_error":
case "rpc_protocol_error":
return "unreachable";
case "upstream_timeout":
case "rpc_decode_error":
case "response_too_large":
return "rpc_error";
default:
return "rpc_error";
}
};
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConnectionHeader } from "./ConnectionHeader.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
}));
import { connectToServer } from "../connection/api.js";
const mockConnect = vi.mocked(connectToServer);
beforeEach(() => {
cleanup();
mockConnect.mockReset();
try {
sessionStorage.clear();
} catch {
// jsdom may not provide sessionStorage
}
});
const successResponse = {
ok: true,
connection: {
status: "connected",
target: "http://127.0.0.1:8765/rpc",
serverStatus: "ok",
storeRoot: "/tmp/store",
durationMs: 12,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
} as const;
const getFirstSection = () => {
const sections = document.querySelectorAll('section[aria-label="Connection"]');
return sections[0] as HTMLElement;
};
describe("ConnectionHeader", () => {
it("renders default target and Connect button", () => {
render(<ConnectionHeader />);
expect(screen.getByLabelText("Workflow JSON-RPC URL")).toHaveValue(
"http://127.0.0.1:8765/rpc",
);
expect(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
).toBeDefined();
});
it("does not automatically call the server", () => {
render(<ConnectionHeader />);
expect(mockConnect).not.toHaveBeenCalled();
});
it("shows connecting state and disables button during request", async () => {
const user = userEvent.setup();
let resolveConnect!: (value: typeof successResponse) => void;
mockConnect.mockReturnValue(
new Promise((r) => {
resolveConnect = r;
}),
);
render(<ConnectionHeader />);
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
expect(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
).toBeDisabled();
expect(
within(getFirstSection()).getByTestId("phase-label"),
).toHaveTextContent("Connecting\u2026");
resolveConnect(successResponse);
});
it("shows connected state with server details", async () => {
const user = userEvent.setup();
mockConnect.mockResolvedValue(successResponse);
render(<ConnectionHeader />);
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
expect(
within(getFirstSection()).getByTestId("phase-label"),
).toHaveTextContent("Connected");
expect(
within(getFirstSection()).getByTestId("server-status"),
).toHaveTextContent("ok");
expect(
within(getFirstSection()).getByTestId("store-root"),
).toHaveTextContent("/tmp/store");
expect(
within(getFirstSection()).getByTestId("duration-ms"),
).toHaveTextContent("12ms");
expect(
within(getFirstSection()).getByRole("button", { name: "Reconnect" }),
).toBeDefined();
});
it("retains typed value on failure", async () => {
const user = userEvent.setup();
mockConnect.mockResolvedValue({
ok: false,
error: { code: "invalid_target", message: "bad target" },
exchange: { request: null, response: null },
});
render(<ConnectionHeader />);
const input = screen.getByLabelText("Workflow JSON-RPC URL");
await user.clear(input);
await user.type(input, "http://bad:9999/rpc");
await user.click(
within(getFirstSection()).getByRole("button", { name: "Connect" }),
);
expect(input).toHaveValue("http://bad:9999/rpc");
expect(
within(getFirstSection()).getByTestId("error-message"),
).toHaveTextContent("bad target");
});
it("restored target still requires explicit connect", async () => {
try {
sessionStorage.setItem(
"lda.workflowConsole.target",
"http://restored:9999/rpc",
);
} catch {
// jsdom may not support sessionStorage
}
render(<ConnectionHeader />);
expect(mockConnect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,108 @@
import { useReducer, type FormEvent } from "react";
import {
connectionReducer,
initialState,
type ConnectionAction,
} from "../app/state.js";
import { connectToServer } from "../connection/api.js";
const handleSubmit = async (
dispatch: React.Dispatch<ConnectionAction>,
target: string,
) => {
dispatch({ type: "submit", target });
try {
const response = await connectToServer(target);
if (response.ok) {
dispatch({ type: "success", data: response });
} else {
dispatch({
type: "failure",
code: response.error.code,
message: response.error.message,
});
}
} catch (e: unknown) {
dispatch({
type: "failure",
code: "rpc_protocol_error",
message: e instanceof Error ? e.message : "unknown error",
});
}
};
const phaseLabel = (phase: string): string => {
switch (phase) {
case "not_configured":
return "Not connected";
case "connecting":
return "Connecting\u2026";
case "connected":
return "Connected";
case "invalid_target":
return "Invalid target";
case "unreachable":
return "Server unreachable";
case "rpc_error":
return "RPC error";
case "malformed_response":
return "Malformed response";
default:
return phase;
}
};
export const ConnectionHeader = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
void handleSubmit(dispatch, state.draftTarget);
};
return (
<section aria-label="Connection">
<form onSubmit={onSubmit}>
<label htmlFor="target-input">Workflow JSON-RPC URL</label>
<input
id="target-input"
type="text"
value={state.draftTarget}
onChange={(e) =>
dispatch({ type: "draft_changed", value: e.target.value })
}
disabled={state.phase === "connecting"}
/>
<button type="submit" disabled={state.phase === "connecting"}>
{state.connectedTarget ? "Reconnect" : "Connect"}
</button>
</form>
<div aria-live="polite" role="status">
<span data-testid="phase-label">{phaseLabel(state.phase)}</span>
{state.phase === "connected" && (
<>
<span data-testid="server-status">
{" "}
&middot; {state.serverStatus}
</span>
<span data-testid="store-root">
{" "}
&middot; {state.storeRoot}
</span>
<span data-testid="duration-ms">
{" "}
&middot; {state.durationMs}ms
</span>
</>
)}
{state.message && (
<span data-testid="error-message">
{" "}
&middot; {state.message}
</span>
)}
</div>
</section>
);
};
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { connectToServer, callOperation } from "./api.js";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
mockFetch.mockReset();
});
const jsonResponse = (data: unknown, status = 200) =>
Promise.resolve(
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
}),
);
describe("connectToServer", () => {
it("posts the exact target to /api/connect", async () => {
mockFetch.mockReturnValue(
jsonResponse({
ok: true,
connection: {
status: "connected",
target: "http://127.0.0.1:8000/rpc",
serverStatus: "ok",
storeRoot: "/tmp/store",
durationMs: 10,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
}),
);
const result = await connectToServer("http://127.0.0.1:8000/rpc");
expect(mockFetch).toHaveBeenCalledWith("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.connection.status).toBe("connected");
}
});
it("returns typed failure DTO instead of throwing for HTTP errors", async () => {
mockFetch.mockReturnValue(
jsonResponse(
{
ok: false,
error: { code: "invalid_target", message: "missing target" },
exchange: { request: null, response: null },
},
400,
),
);
const result = await connectToServer("bad-url");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("invalid_target");
}
});
});
describe("callOperation", () => {
it("posts operation, target, and params to /api/rpc", async () => {
mockFetch.mockReturnValue(
jsonResponse({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: { sources: [], total: 0 },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 5,
}),
);
const result = await callOperation(
"workflow.sources.list",
"http://127.0.0.1:8000/rpc",
{ limit: 10 },
);
expect(mockFetch).toHaveBeenCalledWith("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.sources.list",
target: "http://127.0.0.1:8000/rpc",
params: { limit: 10 },
}),
});
expect(result.ok).toBe(true);
});
it("defaults params to empty object", async () => {
mockFetch.mockReturnValue(
jsonResponse({
ok: true,
operation: "workflow.health",
label: "Health check",
interpreted: { status: "ok" },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
durationMs: 3,
}),
);
await callOperation("workflow.health", "http://127.0.0.1:8000/rpc");
const body = JSON.parse(
(mockFetch.mock.calls[0] as [string, { body: string }])[1].body,
);
expect(body.params).toEqual({});
});
});
describe("error handling", () => {
it("throws for malformed JSON response", async () => {
mockFetch.mockReturnValue(
Promise.resolve(new Response("not json", { status: 200 })),
);
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("malformed JSON");
});
it("throws for empty response", async () => {
mockFetch.mockReturnValue(Promise.resolve(new Response("", { status: 200 })));
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("empty response");
});
it("throws on network failure", async () => {
mockFetch.mockReturnValue(Promise.reject(new Error("network error")));
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("network error");
});
});
+40
View File
@@ -0,0 +1,40 @@
import type {
ConnectResponse,
RpcResponse,
OperationName,
} from "./contracts.js";
const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
const res = await fetch(url, init);
const text = await res.text();
if (!text) {
throw new Error("empty response from server");
}
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error("malformed JSON response from server");
}
return data as T;
};
export const connectToServer = async (
target: string,
): Promise<ConnectResponse> =>
fetchJson<ConnectResponse>("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target }),
});
export const callOperation = async (
operation: OperationName,
target: string,
params: unknown = {},
): Promise<RpcResponse> =>
fetchJson<RpcResponse>("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ operation, target, params }),
});
@@ -0,0 +1,46 @@
export type ConnectionSuccess = {
readonly ok: true;
readonly connection: {
readonly status: "connected";
readonly target: string;
readonly serverStatus: "ok";
readonly storeRoot: string;
readonly durationMs: number;
};
readonly exchange: { readonly request: unknown; readonly response: unknown };
readonly equivalentCli: string;
};
export type OperationSuccess = {
readonly ok: true;
readonly operation: string;
readonly label: string;
readonly interpreted: unknown;
readonly exchange: { readonly request: unknown; readonly response: unknown };
readonly equivalentCli: string;
readonly durationMs: number;
};
export type BrowserErrorCode =
| "invalid_target"
| "unknown_operation"
| "upstream_unreachable"
| "upstream_timeout"
| "rpc_remote_error"
| "rpc_protocol_error"
| "rpc_decode_error"
| "response_too_large";
export type ApiFailure = {
readonly ok: false;
readonly error: { readonly code: BrowserErrorCode; readonly message: string };
readonly exchange: {
readonly request: unknown | null;
readonly response: unknown | null;
};
};
export type ConnectResponse = ConnectionSuccess | ApiFailure;
export type RpcResponse = OperationSuccess | ApiFailure;
export type OperationName = "workflow.health" | "workflow.sources.list";