fix: harden workflow console runtime
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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,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":
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user