fix: harden workflow console rpc boundary
This commit is contained in:
@@ -10,11 +10,12 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/barlow-condensed": "5.2.8",
|
|
||||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||||
|
"@fontsource/barlow-condensed": "5.2.8",
|
||||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7"
|
"react-dom": "19.2.7",
|
||||||
|
"valibot": "1.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const App = () => {
|
|||||||
id: `sources-${Date.now()}`,
|
id: `sources-${Date.now()}`,
|
||||||
operation: "workflow.sources.list",
|
operation: "workflow.sources.list",
|
||||||
label: "Source inventory",
|
label: "Source inventory",
|
||||||
equivalentCli: "uv run wf sources list --limit 50",
|
equivalentCli: result.equivalentCli,
|
||||||
request: result.exchange.request,
|
request: result.exchange.request,
|
||||||
response: result.exchange.response,
|
response: result.exchange.response,
|
||||||
durationMs: result.durationMs,
|
durationMs: result.durationMs,
|
||||||
@@ -72,7 +72,7 @@ export const App = () => {
|
|||||||
id: `sources-${Date.now()}`,
|
id: `sources-${Date.now()}`,
|
||||||
operation: "workflow.sources.list",
|
operation: "workflow.sources.list",
|
||||||
label: "Source inventory",
|
label: "Source inventory",
|
||||||
equivalentCli: "uv run wf sources list --limit 50",
|
equivalentCli: "uv run wf source list --limit 50",
|
||||||
request: result.exchange.request,
|
request: result.exchange.request,
|
||||||
response: result.exchange.response,
|
response: result.exchange.response,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
|
|||||||
@@ -45,25 +45,35 @@ describe("initialState", () => {
|
|||||||
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
|
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores target from localStorage when available", () => {
|
it("restores target from sessionStorage when available", () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
sessionStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
||||||
} catch {
|
} catch {
|
||||||
return; // skip if localStorage unavailable
|
return; // skip if sessionStorage unavailable
|
||||||
}
|
}
|
||||||
const state = initialState();
|
const state = initialState();
|
||||||
expect(state.draftTarget).toBe("http://custom:9999/rpc");
|
expect(state.draftTarget).toBe("http://custom:9999/rpc");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restored target does not trigger automatic connection", () => {
|
it("restored target does not trigger automatic connection", () => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
||||||
|
} catch {
|
||||||
|
return; // skip if sessionStorage unavailable
|
||||||
|
}
|
||||||
|
const state = initialState();
|
||||||
|
expect(state.phase).toBe("not_configured");
|
||||||
|
expect(state.connectedTarget).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores localStorage so persistence is session-scoped", () => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
localStorage.setItem(STORAGE_KEY, "http://custom:9999/rpc");
|
||||||
} catch {
|
} catch {
|
||||||
return; // skip if localStorage unavailable
|
return; // skip if localStorage unavailable
|
||||||
}
|
}
|
||||||
const state = initialState();
|
const state = initialState();
|
||||||
expect(state.phase).toBe("not_configured");
|
expect(state.draftTarget).toBe("http://127.0.0.1:8765/rpc");
|
||||||
expect(state.connectedTarget).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,14 +47,6 @@ export type ConnectionState = {
|
|||||||
|
|
||||||
export const STORAGE_KEY = "lda.workflowConsole.target";
|
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 => {
|
const safeSessionStorage = (): Storage | null => {
|
||||||
try {
|
try {
|
||||||
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
|
||||||
@@ -64,8 +56,8 @@ const safeSessionStorage = (): Storage | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultTarget = (): string => {
|
const getDefaultTarget = (): string => {
|
||||||
const ls = safeLocalStorage();
|
const ss = safeSessionStorage();
|
||||||
return ls?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
|
return ss?.getItem(STORAGE_KEY) ?? "http://127.0.0.1:8765/rpc";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initialState = (): ConnectionState => ({
|
export const initialState = (): ConnectionState => ({
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ describe("error handling", () => {
|
|||||||
).rejects.toThrow("empty response");
|
).rejects.toThrow("empty response");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws for structurally malformed JSON response", async () => {
|
||||||
|
mockFetch.mockReturnValue(jsonResponse({ ok: true, connection: {} }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
connectToServer("http://127.0.0.1:8000/rpc"),
|
||||||
|
).rejects.toThrow("malformed response");
|
||||||
|
});
|
||||||
|
|
||||||
it("throws on network failure", async () => {
|
it("throws on network failure", async () => {
|
||||||
mockFetch.mockReturnValue(Promise.reject(new Error("network error")));
|
mockFetch.mockReturnValue(Promise.reject(new Error("network error")));
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import type {
|
|||||||
RpcResponse,
|
RpcResponse,
|
||||||
OperationName,
|
OperationName,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
|
import { parseConnectResponse, parseRpcResponse } from "./contracts.js";
|
||||||
|
|
||||||
const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
|
const fetchJson = async <T>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
parse: (data: unknown) => T,
|
||||||
|
): Promise<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) {
|
||||||
@@ -16,25 +21,33 @@ const fetchJson = async <T>(url: string, init?: RequestInit): Promise<T> => {
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error("malformed JSON response from server");
|
throw new Error("malformed JSON response from server");
|
||||||
}
|
}
|
||||||
return data as T;
|
return parse(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const connectToServer = async (
|
export const connectToServer = async (
|
||||||
target: string,
|
target: string,
|
||||||
): Promise<ConnectResponse> =>
|
): Promise<ConnectResponse> =>
|
||||||
fetchJson<ConnectResponse>("/api/connect", {
|
fetchJson(
|
||||||
|
"/api/connect",
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
});
|
},
|
||||||
|
parseConnectResponse,
|
||||||
|
);
|
||||||
|
|
||||||
export const callOperation = async (
|
export const callOperation = async (
|
||||||
operation: OperationName,
|
operation: OperationName,
|
||||||
target: string,
|
target: string,
|
||||||
params: unknown = {},
|
params: unknown = {},
|
||||||
): Promise<RpcResponse> =>
|
): Promise<RpcResponse> =>
|
||||||
fetchJson<RpcResponse>("/api/rpc", {
|
fetchJson(
|
||||||
|
"/api/rpc",
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ operation, target, params }),
|
body: JSON.stringify({ operation, target, params }),
|
||||||
});
|
},
|
||||||
|
parseRpcResponse,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,46 +1,79 @@
|
|||||||
export type ConnectionSuccess = {
|
import * as v from "valibot";
|
||||||
readonly ok: true;
|
|
||||||
readonly connection: {
|
const BrowserErrorCodeSchema = v.union([
|
||||||
readonly status: "connected";
|
v.literal("invalid_target"),
|
||||||
readonly target: string;
|
v.literal("unknown_operation"),
|
||||||
readonly serverStatus: "ok";
|
v.literal("upstream_unreachable"),
|
||||||
readonly storeRoot: string;
|
v.literal("upstream_timeout"),
|
||||||
readonly durationMs: number;
|
v.literal("rpc_remote_error"),
|
||||||
};
|
v.literal("rpc_protocol_error"),
|
||||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
v.literal("rpc_decode_error"),
|
||||||
readonly equivalentCli: string;
|
v.literal("response_too_large"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ExchangeSchema = v.object({
|
||||||
|
request: v.nullish(v.unknown(), null),
|
||||||
|
response: v.nullish(v.unknown(), null),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ApiFailureSchema = v.object({
|
||||||
|
ok: v.literal(false),
|
||||||
|
error: v.object({
|
||||||
|
code: BrowserErrorCodeSchema,
|
||||||
|
message: v.string(),
|
||||||
|
}),
|
||||||
|
exchange: ExchangeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ConnectionSuccessSchema = v.object({
|
||||||
|
ok: v.literal(true),
|
||||||
|
connection: v.object({
|
||||||
|
status: v.literal("connected"),
|
||||||
|
target: v.string(),
|
||||||
|
serverStatus: v.literal("ok"),
|
||||||
|
storeRoot: v.string(),
|
||||||
|
durationMs: v.number(),
|
||||||
|
}),
|
||||||
|
exchange: ExchangeSchema,
|
||||||
|
equivalentCli: v.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const OperationNameSchema = v.union([
|
||||||
|
v.literal("workflow.health"),
|
||||||
|
v.literal("workflow.sources.list"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OperationSuccessSchema = v.object({
|
||||||
|
ok: v.literal(true),
|
||||||
|
operation: OperationNameSchema,
|
||||||
|
label: v.string(),
|
||||||
|
interpreted: v.unknown(),
|
||||||
|
exchange: ExchangeSchema,
|
||||||
|
equivalentCli: v.string(),
|
||||||
|
durationMs: v.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ConnectResponseSchema = v.union([ConnectionSuccessSchema, ApiFailureSchema]);
|
||||||
|
const RpcResponseSchema = v.union([OperationSuccessSchema, ApiFailureSchema]);
|
||||||
|
|
||||||
|
export type ConnectionSuccess = v.InferOutput<typeof ConnectionSuccessSchema>;
|
||||||
|
export type OperationSuccess = v.InferOutput<typeof OperationSuccessSchema>;
|
||||||
|
export type BrowserErrorCode = v.InferOutput<typeof BrowserErrorCodeSchema>;
|
||||||
|
export type ApiFailure = v.InferOutput<typeof ApiFailureSchema>;
|
||||||
|
export type ConnectResponse = v.InferOutput<typeof ConnectResponseSchema>;
|
||||||
|
export type RpcResponse = v.InferOutput<typeof RpcResponseSchema>;
|
||||||
|
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");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OperationSuccess = {
|
export const parseConnectResponse = (data: unknown): ConnectResponse =>
|
||||||
readonly ok: true;
|
parseDto(ConnectResponseSchema, data);
|
||||||
readonly operation: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly interpreted: unknown;
|
|
||||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
|
||||||
readonly equivalentCli: string;
|
|
||||||
readonly durationMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserErrorCode =
|
export const parseRpcResponse = (data: unknown): RpcResponse =>
|
||||||
| "invalid_target"
|
parseDto(RpcResponseSchema, data);
|
||||||
| "unknown_operation"
|
|
||||||
| "upstream_unreachable"
|
|
||||||
| "upstream_timeout"
|
|
||||||
| "rpc_remote_error"
|
|
||||||
| "rpc_protocol_error"
|
|
||||||
| "rpc_decode_error"
|
|
||||||
| "response_too_large";
|
|
||||||
|
|
||||||
export type ApiFailure = {
|
|
||||||
readonly ok: false;
|
|
||||||
readonly error: { readonly code: BrowserErrorCode; readonly message: string };
|
|
||||||
readonly exchange: {
|
|
||||||
readonly request: unknown | null;
|
|
||||||
readonly response: unknown | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectResponse = ConnectionSuccess | ApiFailure;
|
|
||||||
export type RpcResponse = OperationSuccess | ApiFailure;
|
|
||||||
|
|
||||||
export type OperationName = "workflow.health" | "workflow.sources.list";
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { createApp, type RunOperation } from "./app.js";
|
import { createApp, type RunOperation } from "./app.js";
|
||||||
import type { OperationExchange } from "@lda/workflow-rpc";
|
import type { OperationExchange } from "@lda/workflow-rpc";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
const makeExchange = (
|
const makeExchange = (
|
||||||
overrides: Partial<OperationExchange> = {},
|
overrides: Partial<OperationExchange> = {},
|
||||||
): OperationExchange => ({
|
): OperationExchange => ({
|
||||||
operation: "workflow.health",
|
operation: "workflow.health",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
label: "Health check",
|
label: "Health check",
|
||||||
interpreted: { status: "ok", store_root: "/tmp/store" },
|
interpreted: { status: "ok", store_root: "/tmp/store" },
|
||||||
exchange: { request: {}, response: { status: "ok" } },
|
exchange: { request: {}, response: { status: "ok" } },
|
||||||
@@ -15,13 +19,16 @@ const makeExchange = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const okRunner: RunOperation = vi.fn(async (operation) =>
|
const okRunner: RunOperation = vi.fn(async (operation) =>
|
||||||
makeExchange({ operation }),
|
makeExchange({ operation, target: "http://127.0.0.1:8765/rpc" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const failRunner =
|
const failRunner =
|
||||||
(code: string, message: string): RunOperation =>
|
(code: string, message: string): RunOperation =>
|
||||||
async () => {
|
async () => {
|
||||||
throw Object.assign(new Error(message), { _tag: code });
|
throw Object.assign(new Error(message), {
|
||||||
|
_tag: code,
|
||||||
|
exchange: { request: { method: "x" }, response: { error: message } },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = createApp({ runOperation: okRunner });
|
const app = createApp({ runOperation: okRunner });
|
||||||
@@ -46,7 +53,7 @@ describe("POST /api/connect", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.ok).toBe(true);
|
expect(body.ok).toBe(true);
|
||||||
expect(body.connection.status).toBe("connected");
|
expect(body.connection.status).toBe("connected");
|
||||||
expect(body.connection.target).toBe("http://127.0.0.1:8000/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(okRunner).toHaveBeenCalledWith(
|
expect(okRunner).toHaveBeenCalledWith(
|
||||||
"workflow.health",
|
"workflow.health",
|
||||||
@@ -139,6 +146,10 @@ describe("error mapping", () => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.ok).toBe(false);
|
expect(body.ok).toBe(false);
|
||||||
expect(body.error.code).toBe("upstream_timeout");
|
expect(body.error.code).toBe("upstream_timeout");
|
||||||
|
expect(body.exchange).toEqual({
|
||||||
|
request: { method: "x" },
|
||||||
|
response: { error: "timed out" },
|
||||||
|
});
|
||||||
expect(body.error.stack).toBeUndefined();
|
expect(body.error.stack).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,3 +249,29 @@ describe("error mapping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("static console routes", () => {
|
||||||
|
it("serves the SPA and keeps unknown API paths as JSON 404", async () => {
|
||||||
|
const consoleRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wf-console-"));
|
||||||
|
fs.mkdirSync(path.join(consoleRoot, "assets"));
|
||||||
|
fs.writeFileSync(path.join(consoleRoot, "index.html"), "<main>console</main>");
|
||||||
|
fs.writeFileSync(path.join(consoleRoot, "assets", "app.js"), "console.log('ok')");
|
||||||
|
try {
|
||||||
|
const staticApp = createApp({ runOperation: okRunner, consoleRoot });
|
||||||
|
|
||||||
|
const index = await staticApp.request("/workflows");
|
||||||
|
expect(index.status).toBe(200);
|
||||||
|
expect(await index.text()).toContain("console");
|
||||||
|
|
||||||
|
const asset = await staticApp.request("/assets/app.js");
|
||||||
|
expect(asset.status).toBe(200);
|
||||||
|
expect(await asset.text()).toContain("ok");
|
||||||
|
|
||||||
|
const unknownApi = await staticApp.request("/api/nope");
|
||||||
|
expect(unknownApi.status).toBe(404);
|
||||||
|
expect(await unknownApi.json()).toEqual({ error: "not found" });
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(consoleRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 type { OperationExchange, OperationName } from "@lda/workflow-rpc";
|
||||||
|
import { addStaticRoutes, validateConsoleRoot } from "./static.js";
|
||||||
|
|
||||||
export type RunOperation = (
|
export type RunOperation = (
|
||||||
operation: OperationName,
|
operation: OperationName,
|
||||||
@@ -51,8 +52,9 @@ const mapErrorToStatus = (
|
|||||||
|
|
||||||
export function createApp(dependencies: {
|
export function createApp(dependencies: {
|
||||||
readonly runOperation: RunOperation;
|
readonly runOperation: RunOperation;
|
||||||
|
readonly consoleRoot?: string;
|
||||||
}): Hono {
|
}): Hono {
|
||||||
const { runOperation } = dependencies;
|
const { runOperation, consoleRoot } = dependencies;
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.get("/api/health", (c) =>
|
app.get("/api/health", (c) =>
|
||||||
@@ -94,10 +96,12 @@ export function createApp(dependencies: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
connection: {
|
connection: {
|
||||||
status: "connected",
|
status: "connected",
|
||||||
target: body.target,
|
target: exchange.target,
|
||||||
serverStatus: "ok",
|
serverStatus: "ok",
|
||||||
storeRoot: (
|
storeRoot: (
|
||||||
exchange.interpreted as { store_root?: string }
|
exchange.interpreted as { storeRoot?: string; store_root?: string }
|
||||||
|
).storeRoot ?? (
|
||||||
|
exchange.interpreted as { storeRoot?: string; store_root?: string }
|
||||||
).store_root ?? "",
|
).store_root ?? "",
|
||||||
durationMs: exchange.durationMs,
|
durationMs: exchange.durationMs,
|
||||||
},
|
},
|
||||||
@@ -115,7 +119,7 @@ export function createApp(dependencies: {
|
|||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code, message: msg },
|
error: { code, message: msg },
|
||||||
exchange: { request: null, response: null },
|
exchange: exchangeFromError(e),
|
||||||
},
|
},
|
||||||
status,
|
status,
|
||||||
);
|
);
|
||||||
@@ -193,12 +197,38 @@ export function createApp(dependencies: {
|
|||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code, message: msg },
|
error: { code, message: msg },
|
||||||
exchange: { request: null, response: null },
|
exchange: exchangeFromError(e),
|
||||||
},
|
},
|
||||||
status,
|
status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (consoleRoot) {
|
||||||
|
validateConsoleRoot(consoleRoot);
|
||||||
|
addStaticRoutes(app, { consoleRoot });
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exchangeFromError = (
|
||||||
|
error: unknown,
|
||||||
|
): { readonly request: unknown | null; readonly response: unknown | null } => {
|
||||||
|
if (error && typeof error === "object" && "exchange" in error) {
|
||||||
|
const exchange = (error as { readonly exchange?: unknown }).exchange;
|
||||||
|
if (exchange && typeof exchange === "object") {
|
||||||
|
return {
|
||||||
|
request:
|
||||||
|
"request" in exchange
|
||||||
|
? (exchange as { readonly request?: unknown }).request ?? null
|
||||||
|
: null,
|
||||||
|
response:
|
||||||
|
"response" in exchange
|
||||||
|
? (exchange as { readonly response?: unknown }).response ?? null
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { request: null, response: null };
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Effect, Layer } from "effect";
|
import { Effect, Layer } from "effect";
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
WorkflowRpc,
|
WorkflowRpc,
|
||||||
makeWorkflowRpcLayer,
|
makeWorkflowRpcLayer,
|
||||||
@@ -16,7 +17,8 @@ if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|||||||
|
|
||||||
const hostname = process.env.WEB_HOST ?? "127.0.0.1";
|
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 runOperation: RunOperation = async (
|
const runOperation: RunOperation = async (
|
||||||
operation: OperationName,
|
operation: OperationName,
|
||||||
@@ -28,7 +30,7 @@ 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 });
|
const app = createApp({ runOperation, consoleRoot });
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
|
|||||||
@@ -23,19 +23,8 @@ export function addStaticRoutes(
|
|||||||
): void {
|
): void {
|
||||||
const { consoleRoot } = options;
|
const { consoleRoot } = options;
|
||||||
|
|
||||||
|
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
||||||
app.use("/assets/*", serveStatic({ root: consoleRoot }));
|
app.use("/assets/*", serveStatic({ root: consoleRoot }));
|
||||||
|
app.get("*", serveStatic({ root: consoleRoot, path: "index.html" }));
|
||||||
app.get("*", (c) => {
|
app.all("*", (c) => c.json({ error: "not found" }, 404));
|
||||||
if (c.req.path.startsWith("/api/")) {
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
return serveStatic({ root: consoleRoot, path: "index.html" })(c);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.all("*", (c) => {
|
|
||||||
if (c.req.path.startsWith("/api/")) {
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
}
|
|
||||||
return c.json({ error: "not found" }, 404);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Data } from "effect";
|
import { Data } from "effect";
|
||||||
|
|
||||||
|
export type RpcExchangeEvidence = {
|
||||||
|
readonly request: unknown | null;
|
||||||
|
readonly response: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
export class InvalidTargetError extends Data.TaggedError("InvalidTargetError")<{
|
export class InvalidTargetError extends Data.TaggedError("InvalidTargetError")<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
}> {}
|
}> {}
|
||||||
@@ -14,31 +19,36 @@ export class UpstreamConnectionError extends Data.TaggedError(
|
|||||||
"UpstreamConnectionError",
|
"UpstreamConnectionError",
|
||||||
)<{
|
)<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export class UpstreamTimeoutError extends Data.TaggedError(
|
export class UpstreamTimeoutError extends Data.TaggedError(
|
||||||
"UpstreamTimeoutError",
|
"UpstreamTimeoutError",
|
||||||
)<{
|
)<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export class UpstreamResponseTooLargeError extends Data.TaggedError(
|
export class UpstreamResponseTooLargeError extends Data.TaggedError(
|
||||||
"UpstreamResponseTooLargeError",
|
"UpstreamResponseTooLargeError",
|
||||||
)<{
|
)<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export class RpcProtocolError extends Data.TaggedError("RpcProtocolError")<{
|
export class RpcProtocolError extends Data.TaggedError("RpcProtocolError")<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly evidence?: string;
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export class RpcRemoteError extends Data.TaggedError("RpcRemoteError")<{
|
export class RpcRemoteError extends Data.TaggedError("RpcRemoteError")<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly code: number;
|
readonly code: number;
|
||||||
readonly data?: string;
|
readonly data?: string;
|
||||||
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|
||||||
export class RpcDecodeError extends Data.TaggedError("RpcDecodeError")<{
|
export class RpcDecodeError extends Data.TaggedError("RpcDecodeError")<{
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
|
readonly exchange?: RpcExchangeEvidence;
|
||||||
}> {}
|
}> {}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Context, Effect, Layer, Ref } from "effect";
|
import { Context, Effect, Layer, Ref, Stream } from "effect";
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
|
||||||
|
import {
|
||||||
|
RpcProtocolError,
|
||||||
|
UpstreamResponseTooLargeError,
|
||||||
|
} from "./errors.js";
|
||||||
|
|
||||||
export type EvidenceRecord = {
|
export type EvidenceRecord = {
|
||||||
readonly request: {
|
readonly request: {
|
||||||
@@ -7,7 +11,10 @@ export type EvidenceRecord = {
|
|||||||
readonly method: string;
|
readonly method: string;
|
||||||
readonly body: unknown;
|
readonly body: unknown;
|
||||||
};
|
};
|
||||||
readonly response: { readonly status: number; readonly body: unknown };
|
readonly response: {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: unknown;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EvidenceRef = Context.GenericTag<Ref.Ref<EvidenceRecord | null>>(
|
export const EvidenceRef = Context.GenericTag<Ref.Ref<EvidenceRecord | null>>(
|
||||||
@@ -31,11 +38,91 @@ const readRequestBody = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (body._tag === "Raw") {
|
if (body._tag === "Raw") {
|
||||||
|
if (typeof body.body !== "string") return body.body;
|
||||||
|
try {
|
||||||
|
return JSON.parse(body.body);
|
||||||
|
} catch {
|
||||||
return body.body;
|
return body.body;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readBoundedText = (
|
||||||
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
|
maxResponseBytes: number,
|
||||||
|
): Effect.Effect<string, never> => {
|
||||||
|
const declaredLength = Number(response.headers["content-length"] ?? "0");
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) {
|
||||||
|
return Effect.die(
|
||||||
|
new UpstreamResponseTooLargeError({
|
||||||
|
message: `response exceeds ${maxResponseBytes} bytes`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stream.runFoldEffect(
|
||||||
|
response.stream,
|
||||||
|
{ chunks: [] as Uint8Array[], size: 0 },
|
||||||
|
(accumulator, chunk) => {
|
||||||
|
const size = accumulator.size + chunk.byteLength;
|
||||||
|
if (size > maxResponseBytes) {
|
||||||
|
return Effect.die(
|
||||||
|
new UpstreamResponseTooLargeError({
|
||||||
|
message: `response exceeds ${maxResponseBytes} bytes`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
accumulator.chunks.push(chunk);
|
||||||
|
return Effect.succeed({ chunks: accumulator.chunks, size });
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.map(({ chunks, size }) => {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}),
|
||||||
|
Effect.orDie,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null;
|
||||||
|
|
||||||
|
const downstreamRpcBodyText = (responseBody: unknown, bodyText: string): string => {
|
||||||
|
if (!isRecord(responseBody) || Array.isArray(responseBody) || !("jsonrpc" in responseBody)) {
|
||||||
|
return bodyText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = "id" in responseBody ? String(responseBody.id) : "";
|
||||||
|
if ("result" in responseBody) {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
_tag: "Exit",
|
||||||
|
requestId,
|
||||||
|
exit: { _tag: "Success", value: responseBody.result },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ("error" in responseBody) {
|
||||||
|
return JSON.stringify([
|
||||||
|
{
|
||||||
|
_tag: "Exit",
|
||||||
|
requestId,
|
||||||
|
exit: {
|
||||||
|
_tag: "Failure",
|
||||||
|
cause: { _tag: "Fail", error: responseBody.error },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return bodyText;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap an HttpClient to capture raw request/response evidence per-call.
|
* Wrap an HttpClient to capture raw request/response evidence per-call.
|
||||||
*
|
*
|
||||||
@@ -45,17 +132,23 @@ const readRequestBody = (
|
|||||||
export const withEvidenceCapture = <E, R>(
|
export const withEvidenceCapture = <E, R>(
|
||||||
client: HttpClient.HttpClient.With<E, R>,
|
client: HttpClient.HttpClient.With<E, R>,
|
||||||
ref: Ref.Ref<EvidenceRecord | null>,
|
ref: Ref.Ref<EvidenceRecord | null>,
|
||||||
|
maxResponseBytes: number,
|
||||||
): HttpClient.HttpClient.With<E, R> =>
|
): HttpClient.HttpClient.With<E, R> =>
|
||||||
client.pipe(
|
client.pipe(
|
||||||
|
HttpClient.tapRequest((request) =>
|
||||||
|
Ref.set(ref, {
|
||||||
|
request: {
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
body: readRequestBody(request),
|
||||||
|
},
|
||||||
|
response: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpClient.transform((responseEffect, request) =>
|
HttpClient.transform((responseEffect, request) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* responseEffect;
|
const response = yield* responseEffect;
|
||||||
|
const bodyText = yield* readBoundedText(response, maxResponseBytes);
|
||||||
// Buffer the body text once so we can record it AND reconstruct the response
|
|
||||||
const bodyText = yield* Effect.catchAll(
|
|
||||||
response.text,
|
|
||||||
() => Effect.succeed(""),
|
|
||||||
);
|
|
||||||
|
|
||||||
let responseBody: unknown;
|
let responseBody: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -73,15 +166,22 @@ export const withEvidenceCapture = <E, R>(
|
|||||||
response: { status: response.status, body: responseBody },
|
response: { status: response.status, body: responseBody },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reconstruct a fresh response from the buffered text so downstream
|
if (response.status >= 300 && response.status < 400) {
|
||||||
// consumers (RpcClient) can still read the body.
|
return yield* Effect.die(
|
||||||
|
new RpcProtocolError({
|
||||||
|
message: "upstream redirects are not allowed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcClient's HTTP protocol expects Effect-RPC response messages, while
|
||||||
|
// the Python wf server returns standard JSON-RPC objects. Preserve the
|
||||||
|
// raw object for evidence and translate only the reconstructed body.
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(bodyText, {
|
new Response(downstreamRpcBodyText(responseBody, bodyText), {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: new Headers(
|
headers: new Headers(response.headers),
|
||||||
Object.entries(response.headers as Record<string, string>),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,127 +1,57 @@
|
|||||||
|
import { Effect, Either } from "effect";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Effect, Ref } from "effect";
|
import { WorkflowRpc, makeWorkflowRpcLayer } from "./service.js";
|
||||||
import { RpcClient, RpcGroup, RpcSerialization } from "@effect/rpc";
|
|
||||||
import { FetchHttpClient, HttpClient } from "@effect/platform";
|
|
||||||
import { WorkflowHealth, WorkflowSourcesList } from "./rpcs.js";
|
|
||||||
import { withEvidenceCapture, type EvidenceRecord } from "./evidence.js";
|
|
||||||
|
|
||||||
const LIVE = process.env.LIVE_PYTHON_SERVER === "1";
|
const LIVE = process.env.LIVE_PYTHON_SERVER === "1";
|
||||||
const describeLive = LIVE ? describe : describe.skip;
|
const describeLive = LIVE ? describe : describe.skip;
|
||||||
|
|
||||||
const TARGET = "http://127.0.0.1:8765/rpc";
|
const TARGET = "http://127.0.0.1:8765/rpc";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const runOperation = (operation: string, params: unknown = {}) =>
|
||||||
const run = <A>(eff: Effect.Effect<A, any, any>): Promise<A> =>
|
Effect.gen(function* () {
|
||||||
Effect.runPromise(eff as Effect.Effect<A, never, never>);
|
const rpc = yield* WorkflowRpc;
|
||||||
|
return yield* rpc.execute(operation, TARGET, params);
|
||||||
|
}).pipe(Effect.provide(makeWorkflowRpcLayer()), Effect.runPromise);
|
||||||
|
|
||||||
describeLive("interop: live Python server", () => {
|
describeLive("interop: live Python server", () => {
|
||||||
it("workflow.health returns ok", async () => {
|
it("workflow.health returns interpreted status and raw JSON-RPC evidence", async () => {
|
||||||
const group = RpcGroup.make(WorkflowHealth);
|
const exchange = await runOperation("workflow.health");
|
||||||
const result = await run(
|
|
||||||
Effect.gen(function* () {
|
expect(exchange.interpreted).toMatchObject({
|
||||||
const client = yield* RpcClient.make(group as any).pipe(
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(
|
|
||||||
RpcClient.layerProtocolHttp({ url: TARGET } as any),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return yield* (client as any)["workflow.health"]({});
|
|
||||||
}).pipe(
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(result).toEqual({
|
|
||||||
status: "ok",
|
status: "ok",
|
||||||
store_root: expect.any(String),
|
storeRoot: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(exchange.exchange.request).toMatchObject({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "workflow.health",
|
||||||
|
});
|
||||||
|
expect(exchange.exchange.response).toMatchObject({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
result: { status: "ok" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("workflow.sources.list returns paginated results", async () => {
|
it("workflow.sources.list returns paginated interpreted results", async () => {
|
||||||
const group = RpcGroup.make(WorkflowSourcesList);
|
const exchange = await runOperation("workflow.sources.list", { limit: 10 });
|
||||||
const result = await run(
|
|
||||||
Effect.gen(function* () {
|
expect(exchange.interpreted).toMatchObject({
|
||||||
const client = yield* RpcClient.make(group as any).pipe(
|
sources: expect.any(Array),
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
total: expect.any(Number),
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(
|
|
||||||
RpcClient.layerProtocolHttp({ url: TARGET } as any),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return yield* (client as any)["workflow.sources.list"]({
|
|
||||||
limit: 10,
|
|
||||||
});
|
});
|
||||||
}).pipe(
|
expect(exchange.equivalentCli).toContain("uv run wf source list");
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const typed = result as {
|
|
||||||
sources: unknown[];
|
|
||||||
next_cursor: string | null;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
expect(typed.sources).toBeInstanceOf(Array);
|
|
||||||
expect(typeof typed.total).toBe("number");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles standard JSON-RPC errors gracefully", async () => {
|
it("unknown operations fail before reaching the server", async () => {
|
||||||
const group = RpcGroup.make(WorkflowSourcesList);
|
const result = await Effect.gen(function* () {
|
||||||
const exit = await Effect.runPromiseExit(
|
const rpc = yield* WorkflowRpc;
|
||||||
Effect.gen(function* () {
|
return yield* rpc
|
||||||
const client = yield* RpcClient.make(group as any).pipe(
|
.execute("workflow.nope", TARGET, {})
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
.pipe(Effect.either);
|
||||||
Effect.provide(FetchHttpClient.layer),
|
}).pipe(Effect.provide(makeWorkflowRpcLayer()), Effect.runPromise);
|
||||||
Effect.provide(
|
|
||||||
RpcClient.layerProtocolHttp({ url: TARGET } as any),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return yield* (client as any)["workflow.sources.list"]({
|
|
||||||
cursor: "nonexistent",
|
|
||||||
});
|
|
||||||
}).pipe(
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
) as Effect.Effect<unknown, never, never>,
|
|
||||||
);
|
|
||||||
expect(exit._tag).toBe("Success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raw evidence capture works", async () => {
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
const evidenceRef = Effect.runSync(Ref.make<EvidenceRecord | null>(null));
|
if (Either.isLeft(result)) {
|
||||||
|
expect(result.left._tag).toBe("UnknownOperationError");
|
||||||
const group = RpcGroup.make(WorkflowHealth);
|
}
|
||||||
const result = await run(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const httpClient = yield* HttpClient.HttpClient;
|
|
||||||
const transformed = withEvidenceCapture(httpClient, evidenceRef);
|
|
||||||
|
|
||||||
const protocolLayer = RpcClient.layerProtocolHttp({
|
|
||||||
url: TARGET,
|
|
||||||
transformClient: () => transformed,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const client = yield* RpcClient.make(group as any).pipe(
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(protocolLayer),
|
|
||||||
);
|
|
||||||
|
|
||||||
return yield* (client as any)["workflow.health"]({});
|
|
||||||
}).pipe(
|
|
||||||
Effect.scoped,
|
|
||||||
Effect.provide(FetchHttpClient.layer),
|
|
||||||
Effect.provide(RpcSerialization.layerJsonRpc()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const evidence = Effect.runSync(Ref.get(evidenceRef));
|
|
||||||
expect(evidence).not.toBeNull();
|
|
||||||
expect(evidence!.request.url).toContain("/rpc");
|
|
||||||
expect(evidence!.response.status).toBe(200);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { Schema } from "effect";
|
||||||
|
import {
|
||||||
|
WorkflowHealthResultSchema,
|
||||||
|
WorkflowSourcesListPayloadSchema,
|
||||||
|
WorkflowSourcesListResultSchema,
|
||||||
|
} from "./rpcs.js";
|
||||||
|
|
||||||
export type OperationMeta = {
|
export type OperationMeta = {
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -16,7 +23,10 @@ const registry: ReadonlyMap<string, OperationMeta> = new Map([
|
|||||||
explanation: "Check if the workflow server is running",
|
explanation: "Check if the workflow server is running",
|
||||||
idempotency: "read",
|
idempotency: "read",
|
||||||
equivalentCli: () => "uv run wf status",
|
equivalentCli: () => "uv run wf status",
|
||||||
interpret: (result) => result,
|
interpret: (result) => {
|
||||||
|
const decoded = Schema.decodeUnknownSync(WorkflowHealthResultSchema)(result);
|
||||||
|
return { status: decoded.status, storeRoot: decoded.store_root };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -27,13 +37,37 @@ const registry: ReadonlyMap<string, OperationMeta> = new Map([
|
|||||||
explanation: "List registered data sources with pagination",
|
explanation: "List registered data sources with pagination",
|
||||||
idempotency: "read",
|
idempotency: "read",
|
||||||
equivalentCli: (params) => {
|
equivalentCli: (params) => {
|
||||||
const p = params as { cursor?: string; limit?: number };
|
const p = Schema.decodeUnknownSync(WorkflowSourcesListPayloadSchema)(
|
||||||
|
params,
|
||||||
|
{ onExcessProperty: "error" },
|
||||||
|
);
|
||||||
const parts = ["uv run wf source list"];
|
const parts = ["uv run wf source list"];
|
||||||
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
||||||
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
|
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
},
|
},
|
||||||
interpret: (result) => result,
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Rpc, RpcGroup } from "@effect/rpc";
|
import { Rpc, RpcGroup } from "@effect/rpc";
|
||||||
import { Schema } from "effect";
|
import { Schema } from "effect";
|
||||||
|
|
||||||
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,
|
||||||
@@ -13,27 +13,33 @@ const SourceSummarySchema = Schema.Struct({
|
|||||||
resource_count: Schema.Number,
|
resource_count: Schema.Number,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkflowHealth = Rpc.make("workflow.health", {
|
export const WorkflowHealthPayloadSchema = Schema.Struct({});
|
||||||
payload: Schema.Struct({}),
|
export const WorkflowHealthResultSchema = Schema.Struct({
|
||||||
success: Schema.Struct({
|
|
||||||
status: Schema.Literal("ok"),
|
status: Schema.Literal("ok"),
|
||||||
store_root: Schema.String,
|
store_root: Schema.String,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
export const WorkflowHealth = Rpc.make("workflow.health", {
|
||||||
|
payload: WorkflowHealthPayloadSchema,
|
||||||
|
success: WorkflowHealthResultSchema,
|
||||||
error: Schema.Never,
|
error: Schema.Never,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
|
export const WorkflowSourcesListPayloadSchema = Schema.Struct({
|
||||||
payload: Schema.Struct({
|
|
||||||
cursor: Schema.optional(Schema.String),
|
cursor: Schema.optional(Schema.String),
|
||||||
limit: Schema.optional(
|
limit: Schema.optional(
|
||||||
Schema.Number.pipe(Schema.greaterThan(0), Schema.lessThan(101)),
|
Schema.Number.pipe(Schema.int(), Schema.between(1, 100)),
|
||||||
),
|
),
|
||||||
}),
|
});
|
||||||
success: 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: Schema.Number,
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
|
||||||
|
payload: WorkflowSourcesListPayloadSchema,
|
||||||
|
success: WorkflowSourcesListResultSchema,
|
||||||
error: Schema.Never,
|
error: Schema.Never,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Effect, Either } from "effect";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
RpcProtocolError,
|
||||||
|
RpcRemoteError,
|
||||||
|
UpstreamConnectionError,
|
||||||
|
UpstreamResponseTooLargeError,
|
||||||
|
UpstreamTimeoutError,
|
||||||
|
} from "./errors.js";
|
||||||
|
import {
|
||||||
|
WorkflowRpc,
|
||||||
|
makeWorkflowRpcLayer,
|
||||||
|
type OperationExchange,
|
||||||
|
type WorkflowRpcOptions,
|
||||||
|
} from "./service.js";
|
||||||
|
|
||||||
|
type JsonRpcRequest = {
|
||||||
|
readonly jsonrpc: "2.0";
|
||||||
|
readonly id: number | string;
|
||||||
|
readonly method: string;
|
||||||
|
readonly params: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyText = async (
|
||||||
|
body: RequestInit["body"] | null | undefined,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (typeof body === "string") return body;
|
||||||
|
if (body instanceof Uint8Array) return new TextDecoder().decode(body);
|
||||||
|
if (body instanceof Blob) return body.text();
|
||||||
|
if (body instanceof ReadableStream) return new Response(body).text();
|
||||||
|
throw new Error("expected JSON-RPC request body");
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBody = async (
|
||||||
|
input: Parameters<typeof globalThis.fetch>[0],
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<JsonRpcRequest> => {
|
||||||
|
if (input instanceof Request) {
|
||||||
|
return JSON.parse(await input.clone().text()) as JsonRpcRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(await bodyText(init?.body ?? null)) as JsonRpcRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const runOperation = (
|
||||||
|
options: WorkflowRpcOptions,
|
||||||
|
operation: "workflow.health" | "workflow.sources.list" = "workflow.health",
|
||||||
|
params: unknown = {},
|
||||||
|
): Promise<OperationExchange> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const rpc = yield* WorkflowRpc;
|
||||||
|
return yield* rpc.execute(
|
||||||
|
operation,
|
||||||
|
"http://127.0.0.1:8765/rpc",
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}).pipe(Effect.provide(makeWorkflowRpcLayer(options)), Effect.runPromise);
|
||||||
|
|
||||||
|
const runEither = (
|
||||||
|
options: WorkflowRpcOptions,
|
||||||
|
): Promise<Either.Either<OperationExchange, unknown>> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const rpc = yield* WorkflowRpc;
|
||||||
|
return yield* rpc
|
||||||
|
.execute("workflow.health", "http://127.0.0.1:8765/rpc", {})
|
||||||
|
.pipe(Effect.either);
|
||||||
|
}).pipe(Effect.provide(makeWorkflowRpcLayer(options)), Effect.runPromise);
|
||||||
|
|
||||||
|
describe("WorkflowRpc", () => {
|
||||||
|
it("uses @effect/rpc and returns exact raw request and response 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: "ok", store_root: "C:/store" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exchange = await runOperation({ fetch });
|
||||||
|
|
||||||
|
expect(exchange.target).toBe("http://127.0.0.1:8765/rpc");
|
||||||
|
expect(exchange.interpreted).toEqual({
|
||||||
|
status: "ok",
|
||||||
|
storeRoot: "C:/store",
|
||||||
|
});
|
||||||
|
expect(exchange.exchange.request).toMatchObject({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "workflow.health",
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(exchange.exchange.response).toMatchObject({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
result: { status: "ok", store_root: "C:/store" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests manual redirect handling", async () => {
|
||||||
|
let redirect: RequestInit["redirect"];
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
redirect = init?.redirect ?? (input instanceof Request ? input.redirect : undefined);
|
||||||
|
const request = await requestBody(input, init);
|
||||||
|
return jsonResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request.id,
|
||||||
|
result: { status: "ok", store_root: "C:/store" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await runOperation({ fetch });
|
||||||
|
|
||||||
|
expect(redirect).toBe("manual");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a standard foreign JSON-RPC error and preserves evidence", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
|
const request = await requestBody(input, init);
|
||||||
|
return jsonResponse({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: request.id,
|
||||||
|
error: { code: -32602, message: "Invalid params", data: { field: "x" } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runEither({ fetch });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(RpcRemoteError);
|
||||||
|
expect((result.left as RpcRemoteError).exchange?.response).toMatchObject({
|
||||||
|
error: { code: -32602, message: "Invalid params" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with a bounded timeout", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = () => new Promise<Response>(() => {});
|
||||||
|
|
||||||
|
const result = await runEither({ fetch, timeoutMilliseconds: 5 });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(UpstreamTimeoutError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps transport failures to upstream connection errors", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = async () => {
|
||||||
|
throw new Error("connection refused");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runEither({ fetch });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(UpstreamConnectionError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a response larger than the configured byte limit", 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: "ok", store_root: "x".repeat(512) },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runEither({ fetch, maxResponseBytes: 128 });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(UpstreamResponseTooLargeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a redirect response instead of decoding it", async () => {
|
||||||
|
const fetch: typeof globalThis.fetch = async () =>
|
||||||
|
new Response("", { status: 302, headers: { location: "/elsewhere" } });
|
||||||
|
|
||||||
|
const result = await runEither({ fetch });
|
||||||
|
|
||||||
|
expect(Either.isLeft(result)).toBe(true);
|
||||||
|
if (Either.isRight(result)) return;
|
||||||
|
expect(result.left).toBeInstanceOf(RpcProtocolError);
|
||||||
|
});
|
||||||
|
});
|
||||||
+244
-220
@@ -1,24 +1,52 @@
|
|||||||
import { Context, Effect, Layer, Ref } from "effect";
|
import { FetchHttpClient } from "@effect/platform";
|
||||||
import { RpcClient, RpcGroup, RpcSerialization } from "@effect/rpc";
|
import { RpcClient, RpcSerialization } from "@effect/rpc";
|
||||||
import { FetchHttpClient, HttpClient } from "@effect/platform";
|
import {
|
||||||
import { normalizeLoopbackTarget } from "./target-policy.js";
|
Cause,
|
||||||
import { getOperationMeta } from "./method-registry.js";
|
Clock,
|
||||||
import { WorkflowHealth, WorkflowSourcesList, WorkflowRpcs } from "./rpcs.js";
|
Context,
|
||||||
import { withEvidenceCapture, type EvidenceRecord } from "./evidence.js";
|
Effect,
|
||||||
|
Layer,
|
||||||
|
Option,
|
||||||
|
Ref,
|
||||||
|
Schema,
|
||||||
|
} from "effect";
|
||||||
import {
|
import {
|
||||||
InvalidTargetError,
|
InvalidTargetError,
|
||||||
UnknownOperationError,
|
RpcDecodeError,
|
||||||
UpstreamConnectionError,
|
type RpcExchangeEvidence,
|
||||||
UpstreamTimeoutError,
|
|
||||||
RpcProtocolError,
|
RpcProtocolError,
|
||||||
RpcRemoteError,
|
RpcRemoteError,
|
||||||
|
UnknownOperationError,
|
||||||
|
UpstreamConnectionError,
|
||||||
|
UpstreamResponseTooLargeError,
|
||||||
|
UpstreamTimeoutError,
|
||||||
} from "./errors.js";
|
} from "./errors.js";
|
||||||
|
import { type EvidenceRecord, withEvidenceCapture } from "./evidence.js";
|
||||||
|
import { getOperationMeta } from "./method-registry.js";
|
||||||
|
import {
|
||||||
|
WorkflowHealthPayloadSchema,
|
||||||
|
WorkflowRpcs,
|
||||||
|
WorkflowSourcesListPayloadSchema,
|
||||||
|
} from "./rpcs.js";
|
||||||
|
import { normalizeLoopbackTarget } from "./target-policy.js";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
|
||||||
|
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
export type OperationName = "workflow.health" | "workflow.sources.list";
|
||||||
|
|
||||||
|
export interface WorkflowRpcOptions {
|
||||||
|
readonly fetch?: typeof globalThis.fetch;
|
||||||
|
readonly timeoutMilliseconds?: number;
|
||||||
|
readonly maxResponseBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationExchange {
|
export interface OperationExchange {
|
||||||
readonly operation: string;
|
readonly operation: OperationName;
|
||||||
|
readonly target: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly interpreted: unknown;
|
readonly interpreted: unknown;
|
||||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
readonly exchange: RpcExchangeEvidence;
|
||||||
readonly equivalentCli: string;
|
readonly equivalentCli: string;
|
||||||
readonly durationMs: number;
|
readonly durationMs: number;
|
||||||
}
|
}
|
||||||
@@ -28,81 +56,224 @@ export type WorkflowRpcError =
|
|||||||
| UnknownOperationError
|
| UnknownOperationError
|
||||||
| UpstreamConnectionError
|
| UpstreamConnectionError
|
||||||
| UpstreamTimeoutError
|
| UpstreamTimeoutError
|
||||||
|
| UpstreamResponseTooLargeError
|
||||||
| RpcProtocolError
|
| RpcProtocolError
|
||||||
| RpcRemoteError;
|
| RpcRemoteError
|
||||||
|
| RpcDecodeError;
|
||||||
|
|
||||||
export type OperationName = "workflow.health" | "workflow.sources.list";
|
const isOperationName = (value: string): value is OperationName =>
|
||||||
|
value === "workflow.health" || value === "workflow.sources.list";
|
||||||
|
|
||||||
const isOperationName = (s: string): s is OperationName =>
|
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
|
||||||
s === "workflow.health" || s === "workflow.sources.list";
|
request: evidence?.request.body ?? null,
|
||||||
|
response: evidence?.response?.body ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
const rpcsByTag = new Map(
|
const responseError = (
|
||||||
[WorkflowHealth, WorkflowSourcesList].map((r) => [r._tag, r] as const),
|
evidence: EvidenceRecord | null,
|
||||||
);
|
): { readonly code: number; readonly message: string; readonly data?: unknown } | null => {
|
||||||
|
const body = evidence?.response?.body;
|
||||||
const interpretResult = (operation: string, result: unknown): unknown => {
|
if (typeof body !== "object" || body === null || !("error" in body)) return null;
|
||||||
const meta = getOperationMeta(operation);
|
const error = body.error;
|
||||||
return meta ? meta.interpret(result) : result;
|
if (typeof error !== "object" || error === null) return null;
|
||||||
|
const code = "code" in error ? Number(error.code) : Number.NaN;
|
||||||
|
const message = "message" in error ? String(error.message) : "remote error";
|
||||||
|
if (!Number.isFinite(code)) return null;
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
...("data" in error ? { data: error.data } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const containsTag = (
|
||||||
|
value: unknown,
|
||||||
|
tags: ReadonlySet<string>,
|
||||||
|
depth = 0,
|
||||||
|
): boolean => {
|
||||||
|
if (depth > 6 || typeof value !== "object" || value === null) return false;
|
||||||
|
if ("_tag" in value && typeof value._tag === "string" && tags.has(value._tag)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.values(value).some((child) => containsTag(child, tags, depth + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const domainErrorFromUnknown = (
|
||||||
|
value: unknown,
|
||||||
|
exchange: RpcExchangeEvidence,
|
||||||
|
): WorkflowRpcError | null => {
|
||||||
|
if (value instanceof UpstreamTimeoutError) {
|
||||||
|
return new UpstreamTimeoutError({ message: value.message, exchange });
|
||||||
|
}
|
||||||
|
if (value instanceof UpstreamResponseTooLargeError) {
|
||||||
|
return new UpstreamResponseTooLargeError({ message: value.message, exchange });
|
||||||
|
}
|
||||||
|
if (value instanceof RpcProtocolError) {
|
||||||
|
return new RpcProtocolError({ message: value.message, exchange });
|
||||||
|
}
|
||||||
|
if (value instanceof RpcDecodeError) {
|
||||||
|
return new RpcDecodeError({ message: value.message, exchange });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Map an Effect failure cause to our domain errors.
|
|
||||||
*
|
|
||||||
* Handles both Effect-native errors (RequestError, ResponseError) and
|
|
||||||
* foreign JSON-RPC errors from the Python server (which lack Effect's
|
|
||||||
* Cause shape). For foreign errors, we read the captured raw response
|
|
||||||
* evidence to extract the JSON-RPC error object.
|
|
||||||
*/
|
|
||||||
const mapCauseToError = (
|
const mapCauseToError = (
|
||||||
cause: unknown,
|
cause: Cause.Cause<unknown>,
|
||||||
evidence: EvidenceRecord | null,
|
evidence: EvidenceRecord | null,
|
||||||
): WorkflowRpcError => {
|
): WorkflowRpcError => {
|
||||||
if (
|
const exchange = toExchange(evidence);
|
||||||
cause &&
|
const remote = responseError(evidence);
|
||||||
typeof cause === "object" &&
|
if (remote) {
|
||||||
"_tag" in cause &&
|
|
||||||
typeof cause._tag === "string"
|
|
||||||
) {
|
|
||||||
const tag = cause._tag;
|
|
||||||
if (tag === "RequestError" || tag === "HttpClientError") {
|
|
||||||
const err = "error" in cause ? cause.error : undefined;
|
|
||||||
const msg = err instanceof Error ? err.message : String(cause);
|
|
||||||
if (msg.toLowerCase().includes("timeout")) {
|
|
||||||
return new UpstreamTimeoutError({ message: msg });
|
|
||||||
}
|
|
||||||
return new UpstreamConnectionError({ message: msg });
|
|
||||||
}
|
|
||||||
if (tag === "ResponseError") {
|
|
||||||
const err = "error" in cause ? cause.error : undefined;
|
|
||||||
const msg = err instanceof Error ? err.message : String(cause);
|
|
||||||
return new UpstreamConnectionError({ message: msg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Foreign JSON-RPC error: extract from captured raw response evidence
|
|
||||||
if (evidence?.response?.body) {
|
|
||||||
const body = evidence.response.body;
|
|
||||||
if (typeof body === "object" && body !== null && "error" in body) {
|
|
||||||
const rpcErr = (body as { error: unknown }).error;
|
|
||||||
if (typeof rpcErr === "object" && rpcErr !== null) {
|
|
||||||
const errObj = rpcErr as {
|
|
||||||
message?: unknown;
|
|
||||||
code?: unknown;
|
|
||||||
data?: unknown;
|
|
||||||
};
|
|
||||||
return new RpcRemoteError({
|
return new RpcRemoteError({
|
||||||
message: String(errObj.message ?? "remote error"),
|
message: remote.message,
|
||||||
code: Number(errObj.code ?? -1),
|
code: remote.code,
|
||||||
...(errObj.data != null ? { data: String(errObj.data) } : {}),
|
...(remote.data === undefined ? {} : { data: JSON.stringify(remote.data) }),
|
||||||
|
exchange,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const failure = Option.getOrUndefined(Cause.failureOption(cause));
|
||||||
|
const defect = Option.getOrUndefined(Cause.dieOption(cause));
|
||||||
|
const known =
|
||||||
|
domainErrorFromUnknown(failure, exchange) ??
|
||||||
|
domainErrorFromUnknown(defect, exchange);
|
||||||
|
if (known) return known;
|
||||||
|
|
||||||
|
if (evidence?.request && !evidence.response) {
|
||||||
|
return new UpstreamConnectionError({
|
||||||
|
message: "could not connect to the workflow RPC server",
|
||||||
|
exchange,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = cause instanceof Error ? cause.message : String(cause);
|
if (containsTag(cause, new Set(["RequestError", "ResponseError"]))) {
|
||||||
return new RpcProtocolError({ message: msg });
|
return new UpstreamConnectionError({
|
||||||
|
message: "could not connect to the workflow RPC server",
|
||||||
|
exchange,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = String(Cause.squash(cause));
|
||||||
|
if (description.toLowerCase().includes("parse") || description.includes("Schema")) {
|
||||||
|
return new RpcDecodeError({
|
||||||
|
message: "workflow RPC result did not match the expected schema",
|
||||||
|
exchange,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new RpcProtocolError({
|
||||||
|
message: "workflow RPC server returned an invalid response",
|
||||||
|
exchange,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decodeParams = <A, I>(
|
||||||
|
schema: Schema.Schema<A, I>,
|
||||||
|
params: unknown,
|
||||||
|
): Effect.Effect<A, RpcDecodeError> =>
|
||||||
|
Schema.decodeUnknown(schema, { onExcessProperty: "error" })(params).pipe(
|
||||||
|
Effect.mapError(
|
||||||
|
() => new RpcDecodeError({ message: "invalid workflow RPC parameters" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeImpl =
|
||||||
|
(options: WorkflowRpcOptions) =>
|
||||||
|
(
|
||||||
|
operation: string,
|
||||||
|
target: string,
|
||||||
|
params: unknown,
|
||||||
|
): Effect.Effect<OperationExchange, WorkflowRpcError> => {
|
||||||
|
let normalizedTarget: string;
|
||||||
|
try {
|
||||||
|
normalizedTarget = normalizeLoopbackTarget(target);
|
||||||
|
} catch (error) {
|
||||||
|
return Effect.fail(
|
||||||
|
error instanceof InvalidTargetError
|
||||||
|
? error
|
||||||
|
: new InvalidTargetError({ message: "invalid workflow RPC target" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isOperationName(operation)) {
|
||||||
|
return Effect.fail(
|
||||||
|
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const evidenceRef = yield* Ref.make<EvidenceRecord | null>(null);
|
||||||
|
const startedAt = yield* Clock.currentTimeMillis;
|
||||||
|
const timeoutMilliseconds =
|
||||||
|
options.timeoutMilliseconds ?? DEFAULT_TIMEOUT_MILLISECONDS;
|
||||||
|
const maxResponseBytes =
|
||||||
|
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
||||||
|
|
||||||
|
const fetchOptionsLayer = Layer.mergeAll(
|
||||||
|
Layer.succeed(FetchHttpClient.Fetch, options.fetch ?? globalThis.fetch),
|
||||||
|
Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" }),
|
||||||
|
);
|
||||||
|
const fetchLayer = Layer.merge(
|
||||||
|
FetchHttpClient.layer.pipe(Layer.provide(fetchOptionsLayer)),
|
||||||
|
RpcSerialization.layerJsonRpc(),
|
||||||
|
);
|
||||||
|
const protocolLayer = RpcClient.layerProtocolHttp({
|
||||||
|
url: normalizedTarget,
|
||||||
|
transformClient: (client) =>
|
||||||
|
withEvidenceCapture(client, evidenceRef, maxResponseBytes),
|
||||||
|
}).pipe(Layer.provide(fetchLayer));
|
||||||
|
|
||||||
|
const call = Effect.gen(function* () {
|
||||||
|
const client = yield* RpcClient.make(WorkflowRpcs);
|
||||||
|
switch (operation) {
|
||||||
|
case "workflow.health": {
|
||||||
|
const payload = yield* decodeParams(WorkflowHealthPayloadSchema, params);
|
||||||
|
return yield* client.workflow.health(payload);
|
||||||
|
}
|
||||||
|
case "workflow.sources.list": {
|
||||||
|
const payload = yield* decodeParams(
|
||||||
|
WorkflowSourcesListPayloadSchema,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return yield* client.workflow["sources.list"](payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(protocolLayer),
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.timeoutFail({
|
||||||
|
duration: timeoutMilliseconds,
|
||||||
|
onTimeout: () =>
|
||||||
|
new UpstreamTimeoutError({
|
||||||
|
message: "workflow RPC request timed out",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Effect.catchAllCause((cause) =>
|
||||||
|
Ref.get(evidenceRef).pipe(
|
||||||
|
Effect.flatMap((evidence) => Effect.fail(mapCauseToError(cause, evidence))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = yield* call;
|
||||||
|
const evidence = yield* Ref.get(evidenceRef);
|
||||||
|
const metadata = getOperationMeta(operation);
|
||||||
|
if (!metadata) {
|
||||||
|
return yield* Effect.fail(
|
||||||
|
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const finishedAt = yield* Clock.currentTimeMillis;
|
||||||
|
return {
|
||||||
|
operation,
|
||||||
|
target: normalizedTarget,
|
||||||
|
label: metadata.label,
|
||||||
|
interpreted: metadata.interpret(result),
|
||||||
|
exchange: toExchange(evidence),
|
||||||
|
equivalentCli: metadata.equivalentCli(params),
|
||||||
|
durationMs: finishedAt - startedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const WorkflowRpc = Context.GenericTag<{
|
export const WorkflowRpc = Context.GenericTag<{
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
operation: string,
|
operation: string,
|
||||||
@@ -111,152 +282,5 @@ export const WorkflowRpc = Context.GenericTag<{
|
|||||||
) => Effect.Effect<OperationExchange, WorkflowRpcError>;
|
) => Effect.Effect<OperationExchange, WorkflowRpcError>;
|
||||||
}>("WorkflowRpc");
|
}>("WorkflowRpc");
|
||||||
|
|
||||||
/**
|
export const makeWorkflowRpcLayer = (options: WorkflowRpcOptions = {}) =>
|
||||||
* Dispatch an RPC call through a typed client.
|
Layer.succeed(WorkflowRpc, { execute: executeImpl(options) });
|
||||||
*
|
|
||||||
* The `client` is the result of `RpcClient.make(group)` which has typed
|
|
||||||
* methods like `client["workflow.health"]({})`. We use a dispatch map
|
|
||||||
* to avoid unsafe casts.
|
|
||||||
*/
|
|
||||||
const dispatchRpc = (
|
|
||||||
client: WorkflowRpcsClient,
|
|
||||||
operation: OperationName,
|
|
||||||
params: unknown,
|
|
||||||
): Effect.Effect<unknown> => {
|
|
||||||
switch (operation) {
|
|
||||||
case "workflow.health":
|
|
||||||
return client["workflow.health"](params as Record<string, never>);
|
|
||||||
case "workflow.sources.list":
|
|
||||||
return client["workflow.sources.list"](
|
|
||||||
params as { cursor?: string; limit?: number },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// The typed client shape produced by RpcClient.make(WorkflowRpcs)
|
|
||||||
type WorkflowRpcsClient = {
|
|
||||||
readonly "workflow.health": (
|
|
||||||
input: Record<string, never>,
|
|
||||||
) => Effect.Effect<{ readonly status: "ok"; readonly store_root: string }>;
|
|
||||||
readonly "workflow.sources.list": (input: {
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
}) => Effect.Effect<{
|
|
||||||
readonly sources: ReadonlyArray<{
|
|
||||||
readonly id: string;
|
|
||||||
readonly kind: string;
|
|
||||||
readonly enabled: boolean;
|
|
||||||
readonly description: string | null;
|
|
||||||
readonly tool_count: number;
|
|
||||||
readonly node_spec_count: number;
|
|
||||||
readonly reducer_count: number;
|
|
||||||
readonly prompt_count: number;
|
|
||||||
readonly resource_count: number;
|
|
||||||
}>;
|
|
||||||
readonly next_cursor: string | null;
|
|
||||||
readonly total: number;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeImpl = (
|
|
||||||
operation: string,
|
|
||||||
target: string,
|
|
||||||
params: unknown,
|
|
||||||
): Effect.Effect<OperationExchange, WorkflowRpcError> => {
|
|
||||||
let normalizedTarget: string;
|
|
||||||
try {
|
|
||||||
normalizedTarget = normalizeLoopbackTarget(target);
|
|
||||||
} catch (e) {
|
|
||||||
return Effect.fail(
|
|
||||||
e instanceof InvalidTargetError
|
|
||||||
? e
|
|
||||||
: new InvalidTargetError({
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = getOperationMeta(operation);
|
|
||||||
if (!meta) {
|
|
||||||
return Effect.fail(
|
|
||||||
new UnknownOperationError({
|
|
||||||
message: `unknown operation: ${operation}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOperationName(operation)) {
|
|
||||||
return Effect.fail(
|
|
||||||
new UnknownOperationError({
|
|
||||||
message: `unsupported operation: ${operation}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const evidenceRef = Ref.unsafeMake<EvidenceRecord | null>(null);
|
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const rpcDef = rpcsByTag.get(operation)!;
|
|
||||||
const group = RpcGroup.make(rpcDef);
|
|
||||||
|
|
||||||
const rpcClient = yield* RpcClient.make(group);
|
|
||||||
const result: unknown = yield* dispatchRpc(
|
|
||||||
rpcClient as unknown as WorkflowRpcsClient,
|
|
||||||
operation,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
|
|
||||||
const evidenceVal = yield* Ref.get(evidenceRef);
|
|
||||||
|
|
||||||
if (
|
|
||||||
result &&
|
|
||||||
typeof result === "object" &&
|
|
||||||
"_tag" in result &&
|
|
||||||
result._tag === "Left"
|
|
||||||
) {
|
|
||||||
const left = (result as unknown as { left: unknown }).left;
|
|
||||||
return yield* Effect.fail(mapCauseToError(left, evidenceVal));
|
|
||||||
}
|
|
||||||
|
|
||||||
const successValue =
|
|
||||||
result &&
|
|
||||||
typeof result === "object" &&
|
|
||||||
"_tag" in result &&
|
|
||||||
result._tag === "Right"
|
|
||||||
? (result as unknown as { right: unknown }).right
|
|
||||||
: result;
|
|
||||||
|
|
||||||
const interpreted = interpretResult(operation, successValue);
|
|
||||||
const durationMs = Date.now() - startTime;
|
|
||||||
|
|
||||||
return {
|
|
||||||
operation,
|
|
||||||
label: meta.label,
|
|
||||||
interpreted,
|
|
||||||
exchange: {
|
|
||||||
request: evidenceVal?.request?.body ?? params,
|
|
||||||
response: evidenceVal?.response?.body ?? successValue,
|
|
||||||
},
|
|
||||||
equivalentCli: meta.equivalentCli(params),
|
|
||||||
durationMs,
|
|
||||||
};
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
RpcClient.layerProtocolHttp({
|
|
||||||
url: normalizedTarget,
|
|
||||||
transformClient: (c) => withEvidenceCapture(c, evidenceRef),
|
|
||||||
}).pipe(
|
|
||||||
Layer.provide(
|
|
||||||
Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerJsonRpc()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.scoped,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const makeWorkflowRpcLayer = Layer.succeed(WorkflowRpc, {
|
|
||||||
execute: executeImpl,
|
|
||||||
});
|
|
||||||
|
|||||||
Generated
+15
@@ -32,6 +32,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.7
|
specifier: 19.2.7
|
||||||
version: 19.2.7([email protected])
|
version: 19.2.7([email protected])
|
||||||
|
valibot:
|
||||||
|
specifier: 1.4.2
|
||||||
|
version: 1.4.2([email protected])
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@testing-library/jest-dom':
|
'@testing-library/jest-dom':
|
||||||
specifier: 6.9.1
|
specifier: 6.9.1
|
||||||
@@ -1051,6 +1054,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==}
|
resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1952,6 +1963,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 6.0.3
|
||||||
|
|
||||||
[email protected](@types/[email protected])([email protected])([email protected]):
|
[email protected](@types/[email protected])([email protected])([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ packages:
|
|||||||
- packages/*
|
- packages/*
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msgpackr-extract: set this to true or false
|
# Optional native acceleration is unnecessary for this local console.
|
||||||
|
msgpackr-extract: false
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@types/[email protected]'
|
- '@types/[email protected]'
|
||||||
|
|||||||
Reference in New Issue
Block a user