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() {
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";
}
};