fix: harden workflow console runtime

This commit is contained in:
lda
2026-07-02 17:35:16 +07:00 Verified
parent bcf31583af
commit ea1276badd
22 changed files with 684 additions and 128 deletions
+117
View File
@@ -0,0 +1,117 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App.js";
import { callOperation, connectToServer } from "../connection/api.js";
import type { ConnectResponse, RpcResponse } from "../connection/contracts.js";
vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(),
callOperation: vi.fn(),
}));
const mockedConnectToServer = vi.mocked(connectToServer);
const mockedCallOperation = vi.mocked(callOperation);
const successfulConnection = (target: string): ConnectResponse => ({
ok: true,
connection: {
status: "connected",
target,
serverStatus: "ok",
storeRoot: "/tmp/store",
durationMs: 11,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
});
const successfulSources = (id: string): RpcResponse => ({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: {
sources: [
{
id,
kind: "python",
enabled: true,
description: null,
counts: {
tools: 1,
nodeSpecs: 1,
reducers: 0,
prompts: 0,
resources: 0,
},
},
],
total: 1,
nextCursor: null,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 7,
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
beforeEach(() => {
mockedConnectToServer.mockReset();
mockedCallOperation.mockReset();
sessionStorage.clear();
});
afterEach(() => {
cleanup();
});
describe("App", () => {
it("shows source inventory errors from rejected source refreshes", async () => {
mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"),
);
mockedCallOperation.mockRejectedValue(new Error("source refresh failed"));
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByTestId("sources-error")).toHaveTextContent(
"source refresh failed",
);
});
it("ignores stale source inventory responses after reconnect", async () => {
const firstSources = deferred<RpcResponse>();
const secondSources = deferred<RpcResponse>();
mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
mockedCallOperation
.mockReturnValueOnce(firstSources.promise)
.mockReturnValueOnce(secondSources.promise);
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await screen.findByTestId("sources-loading");
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
secondSources.resolve(successfulSources("local.second"));
firstSources.resolve(successfulSources("local.first"));
expect(await screen.findByTestId("source-id-local.second")).toHaveTextContent(
"local.second",
);
await waitFor(() => {
expect(screen.queryByTestId("source-id-local.first")).toBeNull();
});
});
});
+57 -33
View File
@@ -1,4 +1,4 @@
import { useReducer, useEffect, useCallback } from "react";
import { useReducer, useEffect, useCallback, useRef } from "react";
import {
connectionReducer,
initialState,
@@ -41,42 +41,55 @@ const parseSources = (
export const App = () => {
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
const connectGeneration = useRef(0);
const sourcesGeneration = useRef(0);
const loadSources = useCallback(
async (target: string) => {
const result = await callOperation(
"workflow.sources.list",
target,
{ limit: 50 },
);
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 {
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: 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,
},
message: e instanceof Error ? e.message : "unknown error",
});
}
},
@@ -90,9 +103,12 @@ export const App = () => {
}, [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({
@@ -116,9 +132,10 @@ export const App = () => {
}
},
(e: unknown) => {
if (connectGeneration.current !== generation) return;
dispatch({
type: "failure",
code: "rpc_protocol_error",
code: errorCodeFromThrown(e),
message: e instanceof Error ? e.message : "unknown error",
});
},
@@ -141,3 +158,10 @@ export const App = () => {
</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";
};
+109
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from "vitest";
import {
connectionReducer,
initialState,
type EvidenceRecord,
type SourceRecord,
type ConnectionState,
type ConnectionAction,
STORAGE_KEY,
@@ -21,6 +23,30 @@ const makeSuccess = (target = "http://127.0.0.1:8765/rpc") =>
equivalentCli: "uv run wf status",
}) as const;
const evidence: EvidenceRecord = {
id: "e1",
operation: "workflow.sources.list",
label: "Source inventory",
equivalentCli: "uv run wf source list",
request: {},
response: {},
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();
@@ -182,6 +208,39 @@ describe("failure", () => {
expect(next.connectedTarget).toBe("http://old:8000/rpc");
expect(next.phase).toBe("unreachable");
});
it("maps malformed responses to the dedicated phase", () => {
const next = connectionReducer(initialState(), {
type: "failure",
code: "malformed_response",
message: "malformed response from server",
});
expect(next.phase).toBe("malformed_response");
});
it("distinguishes the console backend from the workflow server", () => {
const next = connectionReducer(initialState(), {
type: "failure",
code: "console_backend_unreachable",
message: "Console backend unavailable at 127.0.0.1:8787",
});
expect(next.phase).toBe("console_backend_unreachable");
});
it("maps decode and size failures to rpc_error", () => {
for (const code of [
"upstream_timeout",
"rpc_decode_error",
"response_too_large",
]) {
const next = connectionReducer(initialState(), {
type: "failure",
code,
message: code,
});
expect(next.phase).toBe("rpc_error");
}
});
});
describe("reconnect replaces target only on success", () => {
@@ -218,3 +277,53 @@ describe("draft_changed", () => {
expect(next.phase).toBe("not_configured");
});
});
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]);
});
it("appends protocol evidence records", () => {
const state = initialState();
const next = connectionReducer(state, {
type: "evidence_recorded",
record: evidence,
});
expect(next.evidence).toEqual([evidence]);
});
});
+5
View File
@@ -5,6 +5,7 @@ export type ConnectionPhase =
| "connecting"
| "connected"
| "invalid_target"
| "console_backend_unreachable"
| "unreachable"
| "rpc_error"
| "malformed_response";
@@ -183,10 +184,14 @@ const mapCodeToPhase = (code: string): ConnectionPhase => {
switch (code) {
case "invalid_target":
return "invalid_target";
case "console_backend_unreachable":
return "console_backend_unreachable";
case "upstream_unreachable":
case "rpc_remote_error":
case "rpc_protocol_error":
return "unreachable";
case "malformed_response":
return "malformed_response";
case "upstream_timeout":
case "rpc_decode_error":
case "response_too_large":
@@ -11,6 +11,8 @@ const phaseLabel = (phase: string): string => {
return "Connected";
case "invalid_target":
return "Invalid target";
case "console_backend_unreachable":
return "Console backend unavailable";
case "unreachable":
return "Server unreachable";
case "rpc_error":
+21 -2
View File
@@ -64,6 +64,25 @@ describe("connectToServer", () => {
expect(result.error.code).toBe("invalid_target");
}
});
it("accepts future server error codes without rejecting the response", async () => {
mockFetch.mockReturnValue(
jsonResponse(
{
ok: false,
error: { code: "new_server_code", message: "future failure" },
exchange: { request: null, response: null },
},
502,
),
);
const result = await connectToServer("http://127.0.0.1:8000/rpc");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("new_server_code");
}
});
});
describe("callOperation", () => {
@@ -135,7 +154,7 @@ describe("error handling", () => {
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("empty response");
).rejects.toThrow("console backend returned an empty response (HTTP 200)");
});
it("throws for structurally malformed JSON response", async () => {
@@ -143,7 +162,7 @@ describe("error handling", () => {
await expect(
connectToServer("http://127.0.0.1:8000/rpc"),
).rejects.toThrow("malformed response");
).rejects.toThrow("malformed response from server:");
});
it("throws on network failure", async () => {
+3 -1
View File
@@ -13,7 +13,9 @@ const fetchJson = async <T>(
const res = await fetch(url, init);
const text = await res.text();
if (!text) {
throw new Error("empty response from server");
throw new Error(
`console backend returned an empty response (HTTP ${res.status})`,
);
}
let data: unknown;
try {
+5 -3
View File
@@ -1,6 +1,6 @@
import * as v from "valibot";
const BrowserErrorCodeSchema = v.union([
const KnownBrowserErrorCodeSchema = v.union([
v.literal("invalid_target"),
v.literal("unknown_operation"),
v.literal("upstream_unreachable"),
@@ -10,6 +10,7 @@ const BrowserErrorCodeSchema = v.union([
v.literal("rpc_decode_error"),
v.literal("response_too_large"),
]);
const BrowserErrorCodeSchema = v.union([KnownBrowserErrorCodeSchema, v.string()]);
const ExchangeSchema = v.object({
request: v.nullish(v.unknown(), null),
@@ -67,8 +68,9 @@ export type OperationName = v.InferOutput<typeof OperationNameSchema>;
const parseDto = <T>(schema: v.GenericSchema<unknown, T>, data: unknown): T => {
try {
return v.parse(schema, data);
} catch {
throw new Error("malformed response from server");
} catch (error) {
const details = error instanceof Error ? `: ${error.message}` : "";
throw new Error(`malformed response from server${details}`);
}
};
+27 -1
View File
@@ -1,12 +1,38 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
const backendPort = process.env.WEB_PORT ?? "8787";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Server must listen on this port (set via WEB_PORT env or default 8787)
"/api": "http://127.0.0.1:8787",
"/api": {
target: `http://127.0.0.1:${backendPort}`,
configure: (proxy) => {
proxy.on("error", (error, _request, response) => {
if (response.headersSent) return;
const detail =
error instanceof Error ? ` (${error.message})` : "";
response.writeHead(502, {
"content-type": "application/json",
});
response.end(
JSON.stringify({
ok: false,
error: {
code: "console_backend_unreachable",
message:
`Console backend unavailable at 127.0.0.1:${backendPort}. ` +
`Restart pnpm --dir web dev.${detail}`,
},
exchange: { request: null, response: null },
}),
);
});
},
},
},
},
test: {
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"predev": "pnpm --filter @lda/workflow-rpc build",
"dev": "tsx watch src/index.ts",
"dev": "tsx src/index.ts",
"prebuild": "pnpm --filter @lda/workflow-rpc build",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
+17 -1
View File
@@ -11,7 +11,7 @@ const makeExchange = (
operation: "workflow.health",
target: "http://127.0.0.1:8765/rpc",
label: "Health check",
interpreted: { status: "ok", store_root: "/tmp/store" },
interpreted: { status: "ok", storeRoot: "/tmp/store" },
exchange: { request: {}, response: { status: "ok" } },
equivalentCli: "uv run wf status",
durationMs: 12,
@@ -55,6 +55,7 @@ describe("POST /api/connect", () => {
expect(body.connection.status).toBe("connected");
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
expect(body.connection.serverStatus).toBe("ok");
expect(body.connection.storeRoot).toBe("/tmp/store");
expect(okRunner).toHaveBeenCalledWith(
"workflow.health",
"http://127.0.0.1:8000/rpc",
@@ -73,6 +74,21 @@ describe("POST /api/connect", () => {
expect(body.ok).toBe(false);
expect(body.error.code).toBe("invalid_target");
});
it("returns a decode error when health interpretation is malformed", async () => {
const malformedApp = createApp({
runOperation: async () => makeExchange({ interpreted: { status: "ok" } }),
});
const res = await malformedApp.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
});
expect(res.status).toBe(502);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("rpc_decode_error");
});
});
describe("POST /api/rpc", () => {
+38 -12
View File
@@ -1,7 +1,12 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { OperationExchange, OperationName } from "@lda/workflow-rpc";
import {
listOperations,
type OperationExchange,
type OperationName,
type WorkflowHealthInterpreted,
} from "@lda/workflow-rpc";
import { addStaticRoutes, validateConsoleRoot } from "./static.js";
export type RunOperation = (
@@ -20,10 +25,22 @@ type BrowserErrorCode =
| "rpc_decode_error"
| "response_too_large";
const VALID_OPERATIONS: ReadonlySet<string> = new Set([
"workflow.health",
"workflow.sources.list",
]);
const VALID_OPERATIONS: ReadonlySet<string> = new Set(
listOperations().map((operation) => operation.method),
);
const isOperationName = (value: string): value is OperationName =>
VALID_OPERATIONS.has(value);
const isHealthInterpreted = (
value: unknown,
): value is WorkflowHealthInterpreted =>
typeof value === "object" &&
value !== null &&
"status" in value &&
value.status === "ok" &&
"storeRoot" in value &&
typeof value.storeRoot === "string";
const mapErrorToStatus = (
tag: string,
@@ -92,17 +109,26 @@ export function createApp(dependencies: {
body.target,
{},
);
if (!isHealthInterpreted(exchange.interpreted)) {
return c.json(
{
ok: false,
error: {
code: "rpc_decode_error",
message: "workflow.health returned an unexpected shape",
},
exchange: exchange.exchange,
},
502,
);
}
return c.json({
ok: true,
connection: {
status: "connected",
target: exchange.target,
serverStatus: "ok",
storeRoot: (
exchange.interpreted as { storeRoot?: string; store_root?: string }
).storeRoot ?? (
exchange.interpreted as { storeRoot?: string; store_root?: string }
).store_root ?? "",
storeRoot: exchange.interpreted.storeRoot,
durationMs: exchange.durationMs,
},
exchange: exchange.exchange,
@@ -145,7 +171,7 @@ export function createApp(dependencies: {
if (
!body.operation ||
typeof body.operation !== "string" ||
!VALID_OPERATIONS.has(body.operation)
!isOperationName(body.operation)
) {
return c.json(
{
@@ -173,7 +199,7 @@ export function createApp(dependencies: {
try {
const exchange = await runOperation(
body.operation as OperationName,
body.operation,
body.target,
body.params ?? {},
);
+31 -3
View File
@@ -1,5 +1,7 @@
import { Effect, Layer } from "effect";
import { Effect } from "effect";
import { serve } from "@hono/node-server";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
WorkflowRpc,
@@ -19,6 +21,13 @@ const hostname = process.env.WEB_HOST ?? "127.0.0.1";
const liveLayer = makeWorkflowRpcLayer();
const consoleRoot = fileURLToPath(new URL("../../console/dist", import.meta.url));
const consoleIndex = path.join(consoleRoot, "index.html");
const staticConsoleRoot = fs.existsSync(consoleIndex) ? consoleRoot : undefined;
if (!staticConsoleRoot) {
// In dev, Vite serves the console and proxies /api to this server. A missing
// dist directory should not prevent the API proxy from starting.
console.warn(`console dist not found; serving API only: ${consoleRoot}`);
}
const runOperation: RunOperation = async (
operation: OperationName,
@@ -30,12 +39,31 @@ const runOperation: RunOperation = async (
return yield* execute(operation, target, params);
}).pipe(Effect.provide(liveLayer), Effect.runPromise);
const app = createApp({ runOperation, consoleRoot });
let app: ReturnType<typeof createApp>;
try {
app = createApp({
runOperation,
...(staticConsoleRoot ? { consoleRoot: staticConsoleRoot } : {}),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to start workflow console server: ${message}`);
process.exit(1);
}
serve({
const server = serve({
fetch: app.fetch,
hostname,
port,
});
console.log(`workflow console server listening on http://${hostname}:${port}`);
const shutdown = (signal: NodeJS.Signals) => {
console.log(`received ${signal}, stopping workflow console server`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);