fix: harden workflow console runtime
This commit is contained in:
@@ -31,6 +31,7 @@ project_name: "lda-workflow-as-struct"
|
|||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
languages:
|
languages:
|
||||||
- python
|
- python
|
||||||
|
- typescript
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
# the encoding used by text files in the project
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
connectionReducer,
|
connectionReducer,
|
||||||
initialState,
|
initialState,
|
||||||
@@ -41,42 +41,55 @@ const parseSources = (
|
|||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
const [state, dispatch] = useReducer(connectionReducer, null, initialState);
|
||||||
|
const connectGeneration = useRef(0);
|
||||||
|
const sourcesGeneration = useRef(0);
|
||||||
|
|
||||||
const loadSources = useCallback(
|
const loadSources = useCallback(
|
||||||
async (target: string) => {
|
async (target: string) => {
|
||||||
const result = await callOperation(
|
const generation = ++sourcesGeneration.current;
|
||||||
"workflow.sources.list",
|
dispatch({ type: "sources_loading" });
|
||||||
target,
|
try {
|
||||||
{ limit: 50 },
|
const result = await callOperation(
|
||||||
);
|
"workflow.sources.list",
|
||||||
if (result.ok) {
|
target,
|
||||||
const sources = parseSources(result.interpreted);
|
{ limit: 50 },
|
||||||
dispatch({
|
);
|
||||||
type: "sources_loaded",
|
if (sourcesGeneration.current !== generation) return;
|
||||||
sources,
|
if (result.ok) {
|
||||||
evidence: {
|
const sources = parseSources(result.interpreted);
|
||||||
id: `sources-${Date.now()}`,
|
dispatch({
|
||||||
operation: "workflow.sources.list",
|
type: "sources_loaded",
|
||||||
label: "Source inventory",
|
sources,
|
||||||
equivalentCli: result.equivalentCli,
|
evidence: {
|
||||||
request: result.exchange.request,
|
id: `sources-${Date.now()}`,
|
||||||
response: result.exchange.response,
|
operation: "workflow.sources.list",
|
||||||
durationMs: result.durationMs,
|
label: "Source inventory",
|
||||||
},
|
equivalentCli: result.equivalentCli,
|
||||||
});
|
request: result.exchange.request,
|
||||||
} else {
|
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({
|
dispatch({
|
||||||
type: "sources_error",
|
type: "sources_error",
|
||||||
message: result.error.message,
|
message: e instanceof Error ? e.message : "unknown error",
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -90,9 +103,12 @@ export const App = () => {
|
|||||||
}, [state.phase, state.connectedTarget, loadSources]);
|
}, [state.phase, state.connectedTarget, loadSources]);
|
||||||
|
|
||||||
const onSubmit = (target: string) => {
|
const onSubmit = (target: string) => {
|
||||||
|
const generation = ++connectGeneration.current;
|
||||||
|
sourcesGeneration.current++;
|
||||||
dispatch({ type: "submit", target });
|
dispatch({ type: "submit", target });
|
||||||
void connectToServer(target).then(
|
void connectToServer(target).then(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
if (connectGeneration.current !== generation) return;
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
dispatch({ type: "success", data: response });
|
dispatch({ type: "success", data: response });
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -116,9 +132,10 @@ export const App = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
|
if (connectGeneration.current !== generation) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "failure",
|
type: "failure",
|
||||||
code: "rpc_protocol_error",
|
code: errorCodeFromThrown(e),
|
||||||
message: e instanceof Error ? e.message : "unknown error",
|
message: e instanceof Error ? e.message : "unknown error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -141,3 +158,10 @@ export const App = () => {
|
|||||||
</div>
|
</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 {
|
import {
|
||||||
connectionReducer,
|
connectionReducer,
|
||||||
initialState,
|
initialState,
|
||||||
|
type EvidenceRecord,
|
||||||
|
type SourceRecord,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
type ConnectionAction,
|
type ConnectionAction,
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
@@ -21,6 +23,30 @@ const makeSuccess = (target = "http://127.0.0.1:8765/rpc") =>
|
|||||||
equivalentCli: "uv run wf status",
|
equivalentCli: "uv run wf status",
|
||||||
}) as const;
|
}) 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(() => {
|
beforeEach(() => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
@@ -182,6 +208,39 @@ describe("failure", () => {
|
|||||||
expect(next.connectedTarget).toBe("http://old:8000/rpc");
|
expect(next.connectedTarget).toBe("http://old:8000/rpc");
|
||||||
expect(next.phase).toBe("unreachable");
|
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", () => {
|
describe("reconnect replaces target only on success", () => {
|
||||||
@@ -218,3 +277,53 @@ describe("draft_changed", () => {
|
|||||||
expect(next.phase).toBe("not_configured");
|
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"
|
| "connecting"
|
||||||
| "connected"
|
| "connected"
|
||||||
| "invalid_target"
|
| "invalid_target"
|
||||||
|
| "console_backend_unreachable"
|
||||||
| "unreachable"
|
| "unreachable"
|
||||||
| "rpc_error"
|
| "rpc_error"
|
||||||
| "malformed_response";
|
| "malformed_response";
|
||||||
@@ -183,10 +184,14 @@ const mapCodeToPhase = (code: string): ConnectionPhase => {
|
|||||||
switch (code) {
|
switch (code) {
|
||||||
case "invalid_target":
|
case "invalid_target":
|
||||||
return "invalid_target";
|
return "invalid_target";
|
||||||
|
case "console_backend_unreachable":
|
||||||
|
return "console_backend_unreachable";
|
||||||
case "upstream_unreachable":
|
case "upstream_unreachable":
|
||||||
case "rpc_remote_error":
|
case "rpc_remote_error":
|
||||||
case "rpc_protocol_error":
|
case "rpc_protocol_error":
|
||||||
return "unreachable";
|
return "unreachable";
|
||||||
|
case "malformed_response":
|
||||||
|
return "malformed_response";
|
||||||
case "upstream_timeout":
|
case "upstream_timeout":
|
||||||
case "rpc_decode_error":
|
case "rpc_decode_error":
|
||||||
case "response_too_large":
|
case "response_too_large":
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const phaseLabel = (phase: string): string => {
|
|||||||
return "Connected";
|
return "Connected";
|
||||||
case "invalid_target":
|
case "invalid_target":
|
||||||
return "Invalid target";
|
return "Invalid target";
|
||||||
|
case "console_backend_unreachable":
|
||||||
|
return "Console backend unavailable";
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return "Server unreachable";
|
return "Server unreachable";
|
||||||
case "rpc_error":
|
case "rpc_error":
|
||||||
|
|||||||
@@ -64,6 +64,25 @@ describe("connectToServer", () => {
|
|||||||
expect(result.error.code).toBe("invalid_target");
|
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", () => {
|
describe("callOperation", () => {
|
||||||
@@ -135,7 +154,7 @@ describe("error handling", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
connectToServer("http://127.0.0.1:8000/rpc"),
|
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 () => {
|
it("throws for structurally malformed JSON response", async () => {
|
||||||
@@ -143,7 +162,7 @@ describe("error handling", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
connectToServer("http://127.0.0.1:8000/rpc"),
|
connectToServer("http://127.0.0.1:8000/rpc"),
|
||||||
).rejects.toThrow("malformed response");
|
).rejects.toThrow("malformed response from server:");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on network failure", async () => {
|
it("throws on network failure", async () => {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const fetchJson = async <T>(
|
|||||||
const res = await fetch(url, init);
|
const res = await fetch(url, init);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!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;
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
|
|
||||||
const BrowserErrorCodeSchema = v.union([
|
const KnownBrowserErrorCodeSchema = v.union([
|
||||||
v.literal("invalid_target"),
|
v.literal("invalid_target"),
|
||||||
v.literal("unknown_operation"),
|
v.literal("unknown_operation"),
|
||||||
v.literal("upstream_unreachable"),
|
v.literal("upstream_unreachable"),
|
||||||
@@ -10,6 +10,7 @@ const BrowserErrorCodeSchema = v.union([
|
|||||||
v.literal("rpc_decode_error"),
|
v.literal("rpc_decode_error"),
|
||||||
v.literal("response_too_large"),
|
v.literal("response_too_large"),
|
||||||
]);
|
]);
|
||||||
|
const BrowserErrorCodeSchema = v.union([KnownBrowserErrorCodeSchema, v.string()]);
|
||||||
|
|
||||||
const ExchangeSchema = v.object({
|
const ExchangeSchema = v.object({
|
||||||
request: v.nullish(v.unknown(), null),
|
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 => {
|
const parseDto = <T>(schema: v.GenericSchema<unknown, T>, data: unknown): T => {
|
||||||
try {
|
try {
|
||||||
return v.parse(schema, data);
|
return v.parse(schema, data);
|
||||||
} catch {
|
} catch (error) {
|
||||||
throw new Error("malformed response from server");
|
const details = error instanceof Error ? `: ${error.message}` : "";
|
||||||
|
throw new Error(`malformed response from server${details}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
const backendPort = process.env.WEB_PORT ?? "8787";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// Server must listen on this port (set via WEB_PORT env or default 8787)
|
// 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: {
|
test: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm --filter @lda/workflow-rpc build",
|
"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",
|
"prebuild": "pnpm --filter @lda/workflow-rpc build",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const makeExchange = (
|
|||||||
operation: "workflow.health",
|
operation: "workflow.health",
|
||||||
target: "http://127.0.0.1:8765/rpc",
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
label: "Health check",
|
label: "Health check",
|
||||||
interpreted: { status: "ok", store_root: "/tmp/store" },
|
interpreted: { status: "ok", storeRoot: "/tmp/store" },
|
||||||
exchange: { request: {}, response: { status: "ok" } },
|
exchange: { request: {}, response: { status: "ok" } },
|
||||||
equivalentCli: "uv run wf status",
|
equivalentCli: "uv run wf status",
|
||||||
durationMs: 12,
|
durationMs: 12,
|
||||||
@@ -55,6 +55,7 @@ describe("POST /api/connect", () => {
|
|||||||
expect(body.connection.status).toBe("connected");
|
expect(body.connection.status).toBe("connected");
|
||||||
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
|
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
|
||||||
expect(body.connection.serverStatus).toBe("ok");
|
expect(body.connection.serverStatus).toBe("ok");
|
||||||
|
expect(body.connection.storeRoot).toBe("/tmp/store");
|
||||||
expect(okRunner).toHaveBeenCalledWith(
|
expect(okRunner).toHaveBeenCalledWith(
|
||||||
"workflow.health",
|
"workflow.health",
|
||||||
"http://127.0.0.1:8000/rpc",
|
"http://127.0.0.1:8000/rpc",
|
||||||
@@ -73,6 +74,21 @@ describe("POST /api/connect", () => {
|
|||||||
expect(body.ok).toBe(false);
|
expect(body.ok).toBe(false);
|
||||||
expect(body.error.code).toBe("invalid_target");
|
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", () => {
|
describe("POST /api/rpc", () => {
|
||||||
|
|||||||
+38
-12
@@ -1,7 +1,12 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
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";
|
import { addStaticRoutes, validateConsoleRoot } from "./static.js";
|
||||||
|
|
||||||
export type RunOperation = (
|
export type RunOperation = (
|
||||||
@@ -20,10 +25,22 @@ type BrowserErrorCode =
|
|||||||
| "rpc_decode_error"
|
| "rpc_decode_error"
|
||||||
| "response_too_large";
|
| "response_too_large";
|
||||||
|
|
||||||
const VALID_OPERATIONS: ReadonlySet<string> = new Set([
|
const VALID_OPERATIONS: ReadonlySet<string> = new Set(
|
||||||
"workflow.health",
|
listOperations().map((operation) => operation.method),
|
||||||
"workflow.sources.list",
|
);
|
||||||
]);
|
|
||||||
|
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 = (
|
const mapErrorToStatus = (
|
||||||
tag: string,
|
tag: string,
|
||||||
@@ -92,17 +109,26 @@ export function createApp(dependencies: {
|
|||||||
body.target,
|
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({
|
return c.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
connection: {
|
connection: {
|
||||||
status: "connected",
|
status: "connected",
|
||||||
target: exchange.target,
|
target: exchange.target,
|
||||||
serverStatus: "ok",
|
serverStatus: "ok",
|
||||||
storeRoot: (
|
storeRoot: exchange.interpreted.storeRoot,
|
||||||
exchange.interpreted as { storeRoot?: string; store_root?: string }
|
|
||||||
).storeRoot ?? (
|
|
||||||
exchange.interpreted as { storeRoot?: string; store_root?: string }
|
|
||||||
).store_root ?? "",
|
|
||||||
durationMs: exchange.durationMs,
|
durationMs: exchange.durationMs,
|
||||||
},
|
},
|
||||||
exchange: exchange.exchange,
|
exchange: exchange.exchange,
|
||||||
@@ -145,7 +171,7 @@ export function createApp(dependencies: {
|
|||||||
if (
|
if (
|
||||||
!body.operation ||
|
!body.operation ||
|
||||||
typeof body.operation !== "string" ||
|
typeof body.operation !== "string" ||
|
||||||
!VALID_OPERATIONS.has(body.operation)
|
!isOperationName(body.operation)
|
||||||
) {
|
) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -173,7 +199,7 @@ export function createApp(dependencies: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const exchange = await runOperation(
|
const exchange = await runOperation(
|
||||||
body.operation as OperationName,
|
body.operation,
|
||||||
body.target,
|
body.target,
|
||||||
body.params ?? {},
|
body.params ?? {},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Effect, Layer } from "effect";
|
import { Effect } from "effect";
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
WorkflowRpc,
|
WorkflowRpc,
|
||||||
@@ -19,6 +21,13 @@ const hostname = process.env.WEB_HOST ?? "127.0.0.1";
|
|||||||
|
|
||||||
const liveLayer = makeWorkflowRpcLayer();
|
const liveLayer = makeWorkflowRpcLayer();
|
||||||
const consoleRoot = fileURLToPath(new URL("../../console/dist", import.meta.url));
|
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 (
|
const runOperation: RunOperation = async (
|
||||||
operation: OperationName,
|
operation: OperationName,
|
||||||
@@ -30,12 +39,31 @@ const runOperation: RunOperation = async (
|
|||||||
return yield* execute(operation, target, params);
|
return yield* execute(operation, target, params);
|
||||||
}).pipe(Effect.provide(liveLayer), Effect.runPromise);
|
}).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,
|
fetch: app.fetch,
|
||||||
hostname,
|
hostname,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`workflow console server listening on http://${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);
|
||||||
|
|||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@lda/web",
|
"name": "@lda/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.3.0",
|
"packageManager": "pnpm@11.9.0",
|
||||||
"engines": { "node": ">=22" },
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others-on-fail --names server,console --prefix-colors blue,green \"pnpm --filter @lda/web-server dev\" \"pnpm --filter @lda/console dev\"",
|
"dev": "concurrently --kill-others-on-fail --names server,console --prefix-colors blue,green \"pnpm --filter @lda/web-server dev\" \"pnpm --filter @lda/console dev\"",
|
||||||
"test": "pnpm -r --if-present test",
|
"test": "pnpm -r --if-present test",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Context, Effect, Layer, Ref, Stream } from "effect";
|
import { Context, Effect, Layer, Ref, Stream } from "effect";
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
|
import {
|
||||||
|
HttpBody,
|
||||||
|
HttpClient,
|
||||||
|
HttpClientRequest,
|
||||||
|
HttpClientResponse,
|
||||||
|
} from "@effect/platform";
|
||||||
import {
|
import {
|
||||||
RpcProtocolError,
|
RpcProtocolError,
|
||||||
UpstreamResponseTooLargeError,
|
UpstreamResponseTooLargeError,
|
||||||
@@ -48,6 +53,44 @@ const readRequestBody = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bodyText = (body: HttpBody.HttpBody): string | null => {
|
||||||
|
if (body._tag === "Uint8Array") {
|
||||||
|
return new TextDecoder().decode(body.body);
|
||||||
|
}
|
||||||
|
if (body._tag === "Raw" && typeof body.body === "string") {
|
||||||
|
return body.body;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeJsonRpcRequest = (
|
||||||
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
): HttpClientRequest.HttpClientRequest => {
|
||||||
|
const text = bodyText(request.body);
|
||||||
|
if (text === null) return request;
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
if (!isRecord(body) || Array.isArray(body) || body.jsonrpc !== "2.0") {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: body.method,
|
||||||
|
params: "params" in body ? body.params : undefined,
|
||||||
|
id: "id" in body ? body.id : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return HttpClientRequest.modify(request, {
|
||||||
|
body: HttpBody.text(JSON.stringify(sanitized), "application/json"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const readBoundedText = (
|
const readBoundedText = (
|
||||||
response: HttpClientResponse.HttpClientResponse,
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
maxResponseBytes: number,
|
maxResponseBytes: number,
|
||||||
@@ -135,6 +178,7 @@ export const withEvidenceCapture = <E, R>(
|
|||||||
maxResponseBytes: number,
|
maxResponseBytes: number,
|
||||||
): HttpClient.HttpClient.With<E, R> =>
|
): HttpClient.HttpClient.With<E, R> =>
|
||||||
client.pipe(
|
client.pipe(
|
||||||
|
HttpClient.mapRequest(sanitizeJsonRpcRequest),
|
||||||
HttpClient.tapRequest((request) =>
|
HttpClient.tapRequest((request) =>
|
||||||
Ref.set(ref, {
|
Ref.set(ref, {
|
||||||
request: {
|
request: {
|
||||||
@@ -177,11 +221,17 @@ export const withEvidenceCapture = <E, R>(
|
|||||||
// RpcClient's HTTP protocol expects Effect-RPC response messages, while
|
// RpcClient's HTTP protocol expects Effect-RPC response messages, while
|
||||||
// the Python wf server returns standard JSON-RPC objects. Preserve the
|
// the Python wf server returns standard JSON-RPC objects. Preserve the
|
||||||
// raw object for evidence and translate only the reconstructed body.
|
// raw object for evidence and translate only the reconstructed body.
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
// The body is rewritten for Effect-RPC, so upstream body metadata no
|
||||||
|
// longer describes the reconstructed Response.
|
||||||
|
headers.delete("content-length");
|
||||||
|
headers.delete("content-encoding");
|
||||||
|
headers.delete("transfer-encoding");
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(downstreamRpcBodyText(responseBody, bodyText), {
|
new Response(downstreamRpcBodyText(responseBody, bodyText), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: new Headers(response.headers),
|
headers,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export {
|
|||||||
getOperationMeta,
|
getOperationMeta,
|
||||||
listOperations,
|
listOperations,
|
||||||
} from "./method-registry.js";
|
} from "./method-registry.js";
|
||||||
export type { OperationMeta } from "./method-registry.js";
|
export type {
|
||||||
|
OperationMeta,
|
||||||
|
WorkflowHealthInterpreted,
|
||||||
|
WorkflowSourcesListInterpreted,
|
||||||
|
} from "./method-registry.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
EvidenceRef,
|
EvidenceRef,
|
||||||
|
|||||||
@@ -14,66 +14,87 @@ export type OperationMeta = {
|
|||||||
readonly interpret: (result: unknown) => unknown;
|
readonly interpret: (result: unknown) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const registry: ReadonlyMap<string, OperationMeta> = new Map([
|
export type WorkflowHealthInterpreted = {
|
||||||
[
|
readonly status: "ok";
|
||||||
"workflow.health",
|
readonly storeRoot: string;
|
||||||
{
|
};
|
||||||
method: "workflow.health",
|
|
||||||
label: "Health check",
|
export type WorkflowSourcesListInterpreted = {
|
||||||
explanation: "Check if the workflow server is running",
|
readonly sources: ReadonlyArray<{
|
||||||
idempotency: "read",
|
readonly id: string;
|
||||||
equivalentCli: () => "uv run wf status",
|
readonly kind: string;
|
||||||
interpret: (result) => {
|
readonly enabled: boolean;
|
||||||
const decoded = Schema.decodeUnknownSync(WorkflowHealthResultSchema)(result);
|
readonly description: string | null;
|
||||||
return { status: decoded.status, storeRoot: decoded.store_root };
|
readonly counts: {
|
||||||
},
|
readonly tools: number;
|
||||||
|
readonly nodeSpecs: number;
|
||||||
|
readonly reducers: number;
|
||||||
|
readonly prompts: number;
|
||||||
|
readonly resources: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
readonly nextCursor: string | null;
|
||||||
|
readonly total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||||
|
{
|
||||||
|
method: "workflow.health",
|
||||||
|
label: "Health check",
|
||||||
|
explanation: "Check if the workflow server is running",
|
||||||
|
idempotency: "read",
|
||||||
|
equivalentCli: () => "uv run wf status",
|
||||||
|
interpret: (result): WorkflowHealthInterpreted => {
|
||||||
|
const decoded = Schema.decodeUnknownSync(WorkflowHealthResultSchema)(result);
|
||||||
|
return { status: decoded.status, storeRoot: decoded.store_root };
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
[
|
{
|
||||||
"workflow.sources.list",
|
method: "workflow.sources.list",
|
||||||
{
|
label: "List sources",
|
||||||
method: "workflow.sources.list",
|
explanation: "List registered data sources with pagination",
|
||||||
label: "List sources",
|
idempotency: "read",
|
||||||
explanation: "List registered data sources with pagination",
|
equivalentCli: (params) => {
|
||||||
idempotency: "read",
|
const p = Schema.decodeUnknownSync(WorkflowSourcesListPayloadSchema)(
|
||||||
equivalentCli: (params) => {
|
params,
|
||||||
const p = Schema.decodeUnknownSync(WorkflowSourcesListPayloadSchema)(
|
{ onExcessProperty: "error" },
|
||||||
params,
|
);
|
||||||
{ onExcessProperty: "error" },
|
const parts = ["uv run wf source list"];
|
||||||
);
|
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
||||||
const parts = ["uv run wf source list"];
|
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
|
||||||
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
return parts.join(" ");
|
||||||
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
|
|
||||||
return parts.join(" ");
|
|
||||||
},
|
|
||||||
interpret: (result) => {
|
|
||||||
const decoded = Schema.decodeUnknownSync(
|
|
||||||
WorkflowSourcesListResultSchema,
|
|
||||||
)(result);
|
|
||||||
return {
|
|
||||||
sources: decoded.sources.map((source) => ({
|
|
||||||
id: source.id,
|
|
||||||
kind: source.kind,
|
|
||||||
enabled: source.enabled,
|
|
||||||
description: source.description,
|
|
||||||
counts: {
|
|
||||||
tools: source.tool_count,
|
|
||||||
nodeSpecs: source.node_spec_count,
|
|
||||||
reducers: source.reducer_count,
|
|
||||||
prompts: source.prompt_count,
|
|
||||||
resources: source.resource_count,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
nextCursor: decoded.next_cursor,
|
|
||||||
total: decoded.total,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
interpret: (result): WorkflowSourcesListInterpreted => {
|
||||||
]);
|
const decoded = Schema.decodeUnknownSync(
|
||||||
|
WorkflowSourcesListResultSchema,
|
||||||
|
)(result);
|
||||||
|
return {
|
||||||
|
sources: decoded.sources.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
kind: source.kind,
|
||||||
|
enabled: source.enabled,
|
||||||
|
description: source.description,
|
||||||
|
counts: {
|
||||||
|
tools: source.tool_count,
|
||||||
|
nodeSpecs: source.node_spec_count,
|
||||||
|
reducers: source.reducer_count,
|
||||||
|
prompts: source.prompt_count,
|
||||||
|
resources: source.resource_count,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
nextCursor: decoded.next_cursor,
|
||||||
|
total: decoded.total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const registry: ReadonlyMap<string, OperationMeta> = new Map(
|
||||||
|
operationEntries.map((entry) => [entry.method, entry]),
|
||||||
|
);
|
||||||
|
|
||||||
export const getOperationMeta = (method: string): OperationMeta | undefined =>
|
export const getOperationMeta = (method: string): OperationMeta | undefined =>
|
||||||
registry.get(method);
|
registry.get(method);
|
||||||
|
|
||||||
export const listOperations = (): ReadonlyArray<OperationMeta> =>
|
export const listOperations = (): ReadonlyArray<OperationMeta> =>
|
||||||
Array.from(registry.values());
|
operationEntries;
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Rpc, RpcGroup } from "@effect/rpc";
|
import { Rpc, RpcGroup } from "@effect/rpc";
|
||||||
import { Schema } from "effect";
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
const NonNegativeIntegerSchema = Schema.Number.pipe(
|
||||||
|
Schema.int(),
|
||||||
|
Schema.between(0, Number.MAX_SAFE_INTEGER),
|
||||||
|
);
|
||||||
|
|
||||||
export const SourceSummarySchema = Schema.Struct({
|
export const SourceSummarySchema = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
kind: Schema.String,
|
kind: Schema.String,
|
||||||
enabled: Schema.Boolean,
|
enabled: Schema.Boolean,
|
||||||
description: Schema.NullOr(Schema.String),
|
description: Schema.NullOr(Schema.String),
|
||||||
tool_count: Schema.Number,
|
tool_count: NonNegativeIntegerSchema,
|
||||||
node_spec_count: Schema.Number,
|
node_spec_count: NonNegativeIntegerSchema,
|
||||||
reducer_count: Schema.Number,
|
reducer_count: NonNegativeIntegerSchema,
|
||||||
prompt_count: Schema.Number,
|
prompt_count: NonNegativeIntegerSchema,
|
||||||
resource_count: Schema.Number,
|
resource_count: NonNegativeIntegerSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkflowHealthPayloadSchema = Schema.Struct({});
|
export const WorkflowHealthPayloadSchema = Schema.Struct({});
|
||||||
@@ -34,7 +39,7 @@ export const WorkflowSourcesListPayloadSchema = Schema.Struct({
|
|||||||
export const WorkflowSourcesListResultSchema = Schema.Struct({
|
export const WorkflowSourcesListResultSchema = Schema.Struct({
|
||||||
sources: Schema.Array(SourceSummarySchema),
|
sources: Schema.Array(SourceSummarySchema),
|
||||||
next_cursor: Schema.NullOr(Schema.String),
|
next_cursor: Schema.NullOr(Schema.String),
|
||||||
total: Schema.Number,
|
total: NonNegativeIntegerSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
|
export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Effect, Either } from "effect";
|
import { Effect, Either } from "effect";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
RpcDecodeError,
|
||||||
RpcProtocolError,
|
RpcProtocolError,
|
||||||
RpcRemoteError,
|
RpcRemoteError,
|
||||||
UpstreamConnectionError,
|
UpstreamConnectionError,
|
||||||
@@ -138,6 +139,68 @@ describe("WorkflowRpc", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps malformed successful results to decode errors with evidence", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
const request = await requestBody(input, init);
|
||||||
|
return jsonResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request.id,
|
||||||
|
result: { status: "wrong", store_root: "C:/store" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runEither({ fetch });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(RpcDecodeError);
|
||||||
|
expect((result.left as RpcDecodeError).exchange?.response).toMatchObject({
|
||||||
|
result: { status: "wrong", store_root: "C:/store" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid source count shapes as decode errors", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
const request = await requestBody(input, init);
|
||||||
|
return jsonResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request.id,
|
||||||
|
result: {
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "local.demo",
|
||||||
|
kind: "python",
|
||||||
|
enabled: true,
|
||||||
|
description: null,
|
||||||
|
tool_count: -1,
|
||||||
|
node_spec_count: 0,
|
||||||
|
reducer_count: 0,
|
||||||
|
prompt_count: 0,
|
||||||
|
resource_count: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
next_cursor: null,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await Effect.gen(function* () {
|
||||||
|
const rpc = yield* WorkflowRpc;
|
||||||
|
return yield* rpc
|
||||||
|
.execute(
|
||||||
|
"workflow.sources.list",
|
||||||
|
"http://127.0.0.1:8765/rpc",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
.pipe(Effect.either);
|
||||||
|
}).pipe(Effect.provide(makeWorkflowRpcLayer({ fetch })), Effect.runPromise);
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(RpcDecodeError);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails with a bounded timeout", async () => {
|
it("fails with a bounded timeout", async () => {
|
||||||
const fetch: typeof globalThis.fetch = () => new Promise<Response>(() => {});
|
const fetch: typeof globalThis.fetch = () => new Promise<Response>(() => {});
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,13 @@ const mapCauseToError = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const description = String(Cause.squash(cause));
|
const description = String(Cause.squash(cause));
|
||||||
if (description.toLowerCase().includes("parse") || description.includes("Schema")) {
|
const lowerDescription = description.toLowerCase();
|
||||||
|
if (
|
||||||
|
lowerDescription.includes("parse") ||
|
||||||
|
lowerDescription.includes("schema") ||
|
||||||
|
lowerDescription.includes("decode") ||
|
||||||
|
lowerDescription.includes("expected")
|
||||||
|
) {
|
||||||
return new RpcDecodeError({
|
return new RpcDecodeError({
|
||||||
message: "workflow RPC result did not match the expected schema",
|
message: "workflow RPC result did not match the expected schema",
|
||||||
exchange,
|
exchange,
|
||||||
@@ -176,6 +182,27 @@ const decodeParams = <A, I>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const decodeOperationMetadata = (
|
||||||
|
metadata: NonNullable<ReturnType<typeof getOperationMeta>>,
|
||||||
|
result: unknown,
|
||||||
|
params: unknown,
|
||||||
|
evidence: EvidenceRecord | null,
|
||||||
|
): Effect.Effect<
|
||||||
|
{ readonly interpreted: unknown; readonly equivalentCli: string },
|
||||||
|
RpcDecodeError
|
||||||
|
> =>
|
||||||
|
Effect.try({
|
||||||
|
try: () => ({
|
||||||
|
interpreted: metadata.interpret(result),
|
||||||
|
equivalentCli: metadata.equivalentCli(params),
|
||||||
|
}),
|
||||||
|
catch: () =>
|
||||||
|
new RpcDecodeError({
|
||||||
|
message: "workflow RPC result did not match the expected schema",
|
||||||
|
exchange: toExchange(evidence),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const executeImpl =
|
const executeImpl =
|
||||||
(options: WorkflowRpcOptions) =>
|
(options: WorkflowRpcOptions) =>
|
||||||
(
|
(
|
||||||
@@ -261,14 +288,20 @@ const executeImpl =
|
|||||||
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const decodedMetadata = yield* decodeOperationMetadata(
|
||||||
|
metadata,
|
||||||
|
result,
|
||||||
|
params,
|
||||||
|
evidence,
|
||||||
|
);
|
||||||
const finishedAt = yield* Clock.currentTimeMillis;
|
const finishedAt = yield* Clock.currentTimeMillis;
|
||||||
return {
|
return {
|
||||||
operation,
|
operation,
|
||||||
target: normalizedTarget,
|
target: normalizedTarget,
|
||||||
label: metadata.label,
|
label: metadata.label,
|
||||||
interpreted: metadata.interpret(result),
|
interpreted: decodedMetadata.interpreted,
|
||||||
exchange: toExchange(evidence),
|
exchange: toExchange(evidence),
|
||||||
equivalentCli: metadata.equivalentCli(params),
|
equivalentCli: decodedMetadata.equivalentCli,
|
||||||
durationMs: finishedAt - startedAt,
|
durationMs: finishedAt - startedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user