feat: expose workflow console api and RPC service with @effect/rpc

- Replace manual JSON-RPC protocol handling with @effect/rpc typed RPCs
- Add evidence capture layer (per-call Ref, response body buffering/reconstruction)
- Add service module with typed dispatch and layer composition via Layer.mergeAll
- Add Hono API with /api/health, /api/connect, /api/rpc routes
- Add browser DTO contracts, error mapping, body size limits
- Add 13 Hono route tests and 12 target-policy tests passing
- Delete old protocol.ts and protocol.test.ts (replaced by rpcs.ts/service.ts)
This commit is contained in:
lda
2026-07-02 12:18:25 +07:00 Verified
parent e149a39b9e
commit 60a7dd2830
16 changed files with 1188 additions and 205 deletions
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Server must listen on this port (set via WEB_PORT env or default 8787)
"/api": "http://127.0.0.1:8787",
},
},
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi } from "vitest";
import { createApp, type RunOperation } from "./app.js";
import type { OperationExchange } from "@lda/workflow-rpc";
const makeExchange = (
overrides: Partial<OperationExchange> = {},
): OperationExchange => ({
operation: "workflow.health",
label: "Health check",
interpreted: { status: "ok", store_root: "/tmp/store" },
exchange: { request: {}, response: { status: "ok" } },
equivalentCli: "uv run wf status",
durationMs: 12,
...overrides,
});
const okRunner: RunOperation = vi.fn(async (operation) =>
makeExchange({ operation }),
);
const failRunner =
(code: string, message: string): RunOperation =>
async () => {
throw Object.assign(new Error(message), { _tag: code });
};
const app = createApp({ runOperation: okRunner });
describe("GET /api/health", () => {
it("returns 200 with ok status", async () => {
const res = await app.request("/api/health");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true, status: "ok" });
});
});
describe("POST /api/connect", () => {
it("calls workflow.health and returns connected DTO", async () => {
const res = await app.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(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.connection.status).toBe("connected");
expect(body.connection.target).toBe("http://127.0.0.1:8000/rpc");
expect(body.connection.serverStatus).toBe("ok");
expect(okRunner).toHaveBeenCalledWith(
"workflow.health",
"http://127.0.0.1:8000/rpc",
{},
);
});
it("returns 400 when target is missing", async () => {
const res = await app.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("invalid_target");
});
});
describe("POST /api/rpc", () => {
it("invokes the requested operation", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.sources.list",
target: "http://127.0.0.1:8000/rpc",
params: { limit: 10 },
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.operation).toBe("workflow.sources.list");
});
it("returns 400 for unknown operation", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "foo.bar",
target: "http://127.0.0.1:8000/rpc",
}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("unknown_operation");
});
it("returns 400 for invalid JSON body", async () => {
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
});
});
describe("POST body size limit", () => {
it("returns 413 when body exceeds 256 KiB", async () => {
const bigBody = JSON.stringify({ data: "x".repeat(257 * 1024) });
const res = await app.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: bigBody,
});
expect(res.status).toBe(413);
});
});
describe("error mapping", () => {
it("maps upstream timeout to 504", async () => {
const timeoutApp = createApp({
runOperation: failRunner("UpstreamTimeoutError", "timed out"),
});
const res = await timeoutApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
}),
});
expect(res.status).toBe(504);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("upstream_timeout");
expect(body.error.stack).toBeUndefined();
});
it("maps upstream connection error to 502", async () => {
const connApp = createApp({
runOperation: failRunner("UpstreamConnectionError", "connection refused"),
});
const res = await connApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
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("upstream_unreachable");
expect(body.error.stack).toBeUndefined();
});
it("maps RpcRemoteError to 502 with rpc_remote_error", async () => {
const remoteApp = createApp({
runOperation: failRunner("RpcRemoteError", "method not found"),
});
const res = await remoteApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
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_remote_error");
expect(body.error.stack).toBeUndefined();
});
it("maps InvalidTargetError to 400", async () => {
const invalidApp = createApp({
runOperation: failRunner("InvalidTargetError", "bad target"),
});
const res = await invalidApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
target: "not-a-url",
}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("invalid_target");
expect(body.error.stack).toBeUndefined();
});
it("maps UnknownOperationError to 400", async () => {
const unknownApp = createApp({
runOperation: failRunner("UnknownOperationError", "no such op"),
});
const res = await unknownApp.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("unknown_operation");
});
it("never includes stack in error DTOs", async () => {
const apps = [
createApp({ runOperation: failRunner("UpstreamTimeoutError", "t") }),
createApp({ runOperation: failRunner("UpstreamConnectionError", "c") }),
createApp({ runOperation: failRunner("RpcRemoteError", "r") }),
createApp({ runOperation: failRunner("InvalidTargetError", "i") }),
];
for (const a of apps) {
const res = await a.request("/api/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
operation: "workflow.health",
target: "http://127.0.0.1:8000/rpc",
}),
});
const body = await res.json();
expect(body.error?.stack).toBeUndefined();
}
});
});
+204
View File
@@ -0,0 +1,204 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { OperationExchange, OperationName } from "@lda/workflow-rpc";
export type RunOperation = (
operation: OperationName,
target: string,
params: unknown,
) => Promise<OperationExchange>;
type BrowserErrorCode =
| "invalid_target"
| "unknown_operation"
| "upstream_unreachable"
| "upstream_timeout"
| "rpc_remote_error"
| "rpc_protocol_error"
| "rpc_decode_error"
| "response_too_large";
const VALID_OPERATIONS: ReadonlySet<string> = new Set([
"workflow.health",
"workflow.sources.list",
]);
const mapErrorToStatus = (
tag: string,
): { status: ContentfulStatusCode; code: BrowserErrorCode } => {
switch (tag) {
case "InvalidTargetError":
return { status: 400, code: "invalid_target" };
case "UnknownOperationError":
return { status: 400, code: "unknown_operation" };
case "UpstreamConnectionError":
return { status: 502, code: "upstream_unreachable" };
case "UpstreamTimeoutError":
return { status: 504, code: "upstream_timeout" };
case "RpcRemoteError":
return { status: 502, code: "rpc_remote_error" };
case "RpcProtocolError":
return { status: 502, code: "rpc_protocol_error" };
case "RpcDecodeError":
return { status: 502, code: "rpc_decode_error" };
case "UpstreamResponseTooLargeError":
return { status: 502, code: "response_too_large" };
default:
return { status: 500, code: "rpc_protocol_error" };
}
};
export function createApp(dependencies: {
readonly runOperation: RunOperation;
}): Hono {
const { runOperation } = dependencies;
const app = new Hono();
app.get("/api/health", (c) =>
c.json({ ok: true, status: "ok" }),
);
app.use("/api/connect", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/connect", async (c) => {
let body: { target?: string };
try {
body = await c.req.json();
} catch {
return c.json(
{
ok: false,
error: { code: "rpc_protocol_error", message: "invalid JSON body" },
exchange: { request: null, response: null },
},
400,
);
}
if (!body.target || typeof body.target !== "string") {
return c.json(
{
ok: false,
error: { code: "invalid_target", message: "missing target" },
exchange: { request: null, response: null },
},
400,
);
}
try {
const exchange = await runOperation(
"workflow.health",
body.target,
{},
);
return c.json({
ok: true,
connection: {
status: "connected",
target: body.target,
serverStatus: "ok",
storeRoot: (
exchange.interpreted as { store_root?: string }
).store_root ?? "",
durationMs: exchange.durationMs,
},
exchange: exchange.exchange,
equivalentCli: exchange.equivalentCli,
});
} catch (e: unknown) {
const tag =
e && typeof e === "object" && "_tag" in e
? String((e as { _tag: unknown })._tag)
: "Error";
const { status, code } = mapErrorToStatus(tag);
const msg = e instanceof Error ? e.message : String(e);
return c.json(
{
ok: false,
error: { code, message: msg },
exchange: { request: null, response: null },
},
status,
);
}
});
app.use("/api/rpc", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/rpc", async (c) => {
let body: { operation?: string; target?: string; params?: unknown };
try {
body = await c.req.json();
} catch {
return c.json(
{
ok: false,
error: { code: "rpc_protocol_error", message: "invalid JSON body" },
exchange: { request: null, response: null },
},
400,
);
}
if (
!body.operation ||
typeof body.operation !== "string" ||
!VALID_OPERATIONS.has(body.operation)
) {
return c.json(
{
ok: false,
error: {
code: "unknown_operation",
message: `unknown operation: ${body.operation ?? "undefined"}`,
},
exchange: { request: null, response: null },
},
400,
);
}
if (!body.target || typeof body.target !== "string") {
return c.json(
{
ok: false,
error: { code: "invalid_target", message: "missing target" },
exchange: { request: null, response: null },
},
400,
);
}
try {
const exchange = await runOperation(
body.operation as OperationName,
body.target,
body.params ?? {},
);
return c.json({
ok: true,
operation: exchange.operation,
label: exchange.label,
interpreted: exchange.interpreted,
exchange: exchange.exchange,
equivalentCli: exchange.equivalentCli,
durationMs: exchange.durationMs,
});
} catch (e: unknown) {
const tag =
e && typeof e === "object" && "_tag" in e
? String((e as { _tag: unknown })._tag)
: "Error";
const { status, code } = mapErrorToStatus(tag);
const msg = e instanceof Error ? e.message : String(e);
return c.json(
{
ok: false,
error: { code, message: msg },
exchange: { request: null, response: null },
},
status,
);
}
});
return app;
}
+39
View File
@@ -0,0 +1,39 @@
import { Effect, Layer } from "effect";
import { serve } from "@hono/node-server";
import {
WorkflowRpc,
makeWorkflowRpcLayer,
type OperationExchange,
type OperationName,
} from "@lda/workflow-rpc";
import { createApp, type RunOperation } from "./app.js";
const port = Number(process.env.WEB_PORT ?? "8787");
if (Number.isNaN(port) || port < 1 || port > 65535) {
console.error(`Invalid WEB_PORT: ${process.env.WEB_PORT}`);
process.exit(1);
}
const hostname = process.env.WEB_HOST ?? "127.0.0.1";
const liveLayer = makeWorkflowRpcLayer;
const runOperation: RunOperation = async (
operation: OperationName,
target: string,
params: unknown,
): Promise<OperationExchange> =>
Effect.gen(function* () {
const { execute } = yield* WorkflowRpc;
return yield* execute(operation, target, params);
}).pipe(Effect.provide(liveLayer), Effect.runPromise);
const app = createApp({ runOperation });
serve({
fetch: app.fetch,
hostname,
port,
});
console.log(`workflow console server listening on http://${hostname}:${port}`);
+2 -1
View File
@@ -6,7 +6,8 @@
"composite": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"references": [{ "path": "../../packages/rpc" }]
+2
View File
@@ -14,6 +14,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@effect/platform": "0.96.2",
"@effect/rpc": "0.75.1",
"effect": "3.21.4"
},
"devDependencies": {
+89
View File
@@ -0,0 +1,89 @@
import { Context, Effect, Layer, Ref } from "effect";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
export type EvidenceRecord = {
readonly request: {
readonly url: string;
readonly method: string;
readonly body: unknown;
};
readonly response: { readonly status: number; readonly body: unknown };
};
export const EvidenceRef = Context.GenericTag<Ref.Ref<EvidenceRecord | null>>(
"EvidenceRef",
);
export const makeEvidenceLayer = Layer.sync(
EvidenceRef,
() => Ref.unsafeMake<EvidenceRecord | null>(null),
);
const readRequestBody = (
request: HttpClientRequest.HttpClientRequest,
): unknown => {
const body = request.body;
if (body._tag === "Uint8Array") {
try {
return JSON.parse(new TextDecoder().decode(body.body));
} catch {
return body.body;
}
}
if (body._tag === "Raw") {
return body.body;
}
return null;
};
/**
* Wrap an HttpClient to capture raw request/response evidence per-call.
*
* Buffers the response body once, records it for the evidence drawer,
* then reconstructs the response so RpcClient can still read it.
*/
export const withEvidenceCapture = <E, R>(
client: HttpClient.HttpClient.With<E, R>,
ref: Ref.Ref<EvidenceRecord | null>,
): HttpClient.HttpClient.With<E, R> =>
client.pipe(
HttpClient.transform((responseEffect, request) =>
Effect.gen(function* () {
const response = yield* responseEffect;
// 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;
try {
responseBody = JSON.parse(bodyText);
} catch {
responseBody = bodyText;
}
yield* Ref.set(ref, {
request: {
url: request.url,
method: request.method,
body: readRequestBody(request),
},
response: { status: response.status, body: responseBody },
});
// Reconstruct a fresh response from the buffered text so downstream
// consumers (RpcClient) can still read the body.
return HttpClientResponse.fromWeb(
request,
new Response(bodyText, {
status: response.status,
headers: new Headers(
Object.entries(response.headers as Record<string, string>),
),
}),
);
}),
),
);
+16 -7
View File
@@ -11,11 +11,20 @@ export {
export { normalizeLoopbackTarget } from "./target-policy.js";
export { decodeRpcResponse } from "./protocol.js";
export { WorkflowHealth, WorkflowSourcesList, WorkflowRpcs } from "./rpcs.js";
export type {
JsonRpcRequest,
JsonRpcResponse,
JsonRpcSuccess,
JsonRpcFailure,
} from "./protocol.js";
export { WorkflowRpc, makeWorkflowRpcLayer } from "./service.js";
export type { OperationExchange, WorkflowRpcError, OperationName } from "./service.js";
export {
getOperationMeta,
listOperations,
} from "./method-registry.js";
export type { OperationMeta } from "./method-registry.js";
export {
EvidenceRef,
makeEvidenceLayer,
withEvidenceCapture,
} from "./evidence.js";
export type { EvidenceRecord } from "./evidence.js";
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { Effect, Ref } from "effect";
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 describeLive = LIVE ? describe : describe.skip;
const TARGET = "http://127.0.0.1:8765/rpc";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const run = <A>(eff: Effect.Effect<A, any, any>): Promise<A> =>
Effect.runPromise(eff as Effect.Effect<A, never, never>);
describeLive("interop: live Python server", () => {
it("workflow.health returns ok", async () => {
const group = RpcGroup.make(WorkflowHealth);
const result = await run(
Effect.gen(function* () {
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",
store_root: expect.any(String),
});
});
it("workflow.sources.list returns paginated results", async () => {
const group = RpcGroup.make(WorkflowSourcesList);
const result = await run(
Effect.gen(function* () {
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.sources.list"]({
limit: 10,
});
}).pipe(
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 () => {
const group = RpcGroup.make(WorkflowSourcesList);
const exit = await Effect.runPromiseExit(
Effect.gen(function* () {
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.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 () => {
const evidenceRef = Effect.runSync(Ref.make<EvidenceRecord | null>(null));
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);
});
});
+45
View File
@@ -0,0 +1,45 @@
export type OperationMeta = {
readonly method: string;
readonly label: string;
readonly explanation: string;
readonly idempotency: "read";
readonly equivalentCli: (params: unknown) => string;
readonly interpret: (result: unknown) => unknown;
};
const registry: ReadonlyMap<string, OperationMeta> = new Map([
[
"workflow.health",
{
method: "workflow.health",
label: "Health check",
explanation: "Check if the workflow server is running",
idempotency: "read",
equivalentCli: () => "uv run wf status",
interpret: (result) => result,
},
],
[
"workflow.sources.list",
{
method: "workflow.sources.list",
label: "List sources",
explanation: "List registered data sources with pagination",
idempotency: "read",
equivalentCli: (params) => {
const p = params as { cursor?: string; limit?: number };
const parts = ["uv run wf source list"];
if (p.limit != null) parts.push(`--limit ${p.limit}`);
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
return parts.join(" ");
},
interpret: (result) => result,
},
],
]);
export const getOperationMeta = (method: string): OperationMeta | undefined =>
registry.get(method);
export const listOperations = (): ReadonlyArray<OperationMeta> =>
Array.from(registry.values());
-84
View File
@@ -1,84 +0,0 @@
import { describe, expect, it } from "vitest";
import { RpcDecodeError, RpcProtocolError, decodeRpcResponse } from "./protocol.js";
describe("decodeRpcResponse", () => {
it("decodes success envelope with matching string id", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
};
expect(decodeRpcResponse(payload, "req-1")).toEqual({
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
});
});
it("decodes error envelope with matching string id", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
error: { code: -32000, message: "server error" },
};
const response = decodeRpcResponse(payload, "req-1");
expect(response).toEqual({
jsonrpc: "2.0",
id: "req-1",
error: { code: -32000, message: "server error" },
});
});
it("rejects mismatched id", () => {
const payload = {
jsonrpc: "2.0",
id: "other",
result: { status: "ok" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(
RpcProtocolError,
);
});
it("rejects envelope with both result and error", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
result: { status: "ok" },
error: { code: -32000, message: "err" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects envelope with neither result nor error", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects wrong jsonrpc version", () => {
const payload = {
jsonrpc: "1.0",
id: "req-1",
result: {},
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects malformed error object", () => {
const payload = {
jsonrpc: "2.0",
id: "req-1",
error: { message: "missing code" },
};
expect(() => decodeRpcResponse(payload, "req-1")).toThrow(RpcDecodeError);
});
it("rejects non-object payload", () => {
expect(() => decodeRpcResponse("not an object", "req-1")).toThrow(
RpcDecodeError,
);
});
});
-113
View File
@@ -1,113 +0,0 @@
import { Schema } from "effect";
import { RpcDecodeError, RpcProtocolError } from "./errors.js";
export { RpcDecodeError, RpcProtocolError };
const JsonRpcVersion = Schema.Literal("2.0");
const JsonRpcErrorObject = Schema.Struct({
code: Schema.Number,
message: Schema.String,
data: Schema.optional(Schema.String),
});
const decodeJsonRpcVersion = Schema.decodeUnknownSync(JsonRpcVersion);
const decodeErrorObject = Schema.decodeUnknownSync(JsonRpcErrorObject);
export type JsonRpcRequest = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly method: string;
readonly params: unknown;
};
export type JsonRpcSuccess = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly result: unknown;
};
export type JsonRpcFailure = {
readonly jsonrpc: "2.0";
readonly id: string;
readonly error: {
readonly code: number;
readonly message: string;
readonly data?: string;
};
};
export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
function throwRpcDecode(message: string): never {
throw new RpcDecodeError({ message });
}
export function decodeRpcResponse(
value: unknown,
expectedId: string,
): JsonRpcResponse {
if (typeof value !== "object" || value === null) {
throwRpcDecode("response must be a JSON object");
}
const obj = value as Record<string, unknown>;
if (!("jsonrpc" in obj) || !("id" in obj)) {
throwRpcDecode("response must contain 'jsonrpc' and 'id' fields");
}
try {
decodeJsonRpcVersion(obj.jsonrpc, { onExcessProperty: "error" });
} catch {
throwRpcDecode(`jsonrpc version must be "2.0", got ${JSON.stringify(obj.jsonrpc)}`);
}
if (typeof obj.id !== "string") {
throwRpcDecode(`response id must be a string, got ${typeof obj.id}`);
}
if (obj.id !== expectedId) {
throw new RpcProtocolError({
message: `response id "${obj.id}" does not match expected id "${expectedId}"`,
evidence: JSON.stringify(obj),
});
}
const hasResult = "result" in obj;
const hasError = "error" in obj;
if (hasResult && hasError) {
throwRpcDecode("response must not contain both 'result' and 'error'");
}
if (!hasResult && !hasError) {
throwRpcDecode("response must contain exactly one of 'result' or 'error'");
}
if (hasResult) {
return { jsonrpc: "2.0", id: obj.id, result: obj.result };
}
if (typeof obj.error !== "object" || obj.error === null) {
throwRpcDecode("'error' must be an object");
}
let errorObj: { readonly code: number; readonly message: string; readonly data?: string | undefined };
try {
errorObj = decodeErrorObject(obj.error, { onExcessProperty: "error" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throwRpcDecode(`invalid error object: ${msg}`);
}
const error: { code: number; message: string; data?: string } = {
code: errorObj.code,
message: errorObj.message,
};
if (errorObj.data !== undefined) {
error.data = errorObj.data;
}
return { jsonrpc: "2.0", id: obj.id, error };
}
+40
View File
@@ -0,0 +1,40 @@
import { Rpc, RpcGroup } from "@effect/rpc";
import { Schema } from "effect";
const SourceSummarySchema = Schema.Struct({
id: Schema.String,
kind: Schema.String,
enabled: Schema.Boolean,
description: Schema.NullOr(Schema.String),
tool_count: Schema.Number,
node_spec_count: Schema.Number,
reducer_count: Schema.Number,
prompt_count: Schema.Number,
resource_count: Schema.Number,
});
export const WorkflowHealth = Rpc.make("workflow.health", {
payload: Schema.Struct({}),
success: Schema.Struct({
status: Schema.Literal("ok"),
store_root: Schema.String,
}),
error: Schema.Never,
});
export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
payload: Schema.Struct({
cursor: Schema.optional(Schema.String),
limit: Schema.optional(
Schema.Number.pipe(Schema.greaterThan(0), Schema.lessThan(101)),
),
}),
success: Schema.Struct({
sources: Schema.Array(SourceSummarySchema),
next_cursor: Schema.NullOr(Schema.String),
total: Schema.Number,
}),
error: Schema.Never,
});
export const WorkflowRpcs = RpcGroup.make(WorkflowHealth, WorkflowSourcesList);
+262
View File
@@ -0,0 +1,262 @@
import { Context, Effect, Layer, Ref } from "effect";
import { RpcClient, RpcGroup, RpcSerialization } from "@effect/rpc";
import { FetchHttpClient, HttpClient } from "@effect/platform";
import { normalizeLoopbackTarget } from "./target-policy.js";
import { getOperationMeta } from "./method-registry.js";
import { WorkflowHealth, WorkflowSourcesList, WorkflowRpcs } from "./rpcs.js";
import { withEvidenceCapture, type EvidenceRecord } from "./evidence.js";
import {
InvalidTargetError,
UnknownOperationError,
UpstreamConnectionError,
UpstreamTimeoutError,
RpcProtocolError,
RpcRemoteError,
} from "./errors.js";
export interface OperationExchange {
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 WorkflowRpcError =
| InvalidTargetError
| UnknownOperationError
| UpstreamConnectionError
| UpstreamTimeoutError
| RpcProtocolError
| RpcRemoteError;
export type OperationName = "workflow.health" | "workflow.sources.list";
const isOperationName = (s: string): s is OperationName =>
s === "workflow.health" || s === "workflow.sources.list";
const rpcsByTag = new Map(
[WorkflowHealth, WorkflowSourcesList].map((r) => [r._tag, r] as const),
);
const interpretResult = (operation: string, result: unknown): unknown => {
const meta = getOperationMeta(operation);
return meta ? meta.interpret(result) : result;
};
/**
* 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 = (
cause: unknown,
evidence: EvidenceRecord | null,
): WorkflowRpcError => {
if (
cause &&
typeof cause === "object" &&
"_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({
message: String(errObj.message ?? "remote error"),
code: Number(errObj.code ?? -1),
...(errObj.data != null ? { data: String(errObj.data) } : {}),
});
}
}
}
const msg = cause instanceof Error ? cause.message : String(cause);
return new RpcProtocolError({ message: msg });
};
export const WorkflowRpc = Context.GenericTag<{
readonly execute: (
operation: string,
target: string,
params: unknown,
) => Effect.Effect<OperationExchange, WorkflowRpcError>;
}>("WorkflowRpc");
/**
* Dispatch an RPC call through a typed client.
*
* 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,
});
+120
View File
@@ -88,6 +88,12 @@ importers:
packages/rpc:
dependencies:
'@effect/platform':
specifier: 0.96.2
version: 0.96.2([email protected])
'@effect/rpc':
specifier: 0.75.1
version: 0.75.1(@effect/[email protected]([email protected]))([email protected])
effect:
specifier: 3.21.4
version: 3.21.4
@@ -171,6 +177,17 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@effect/[email protected]':
resolution: {integrity: sha512-oJm3UztdzZvK7BXkFSV3IdyGuqQrhLmViG/hMDZh99ski7aADesNuv19z4R0heH3bAXnSt/Nb8O9KJQ886C0Tg==}
peerDependencies:
effect: ^3.21.4
'@effect/[email protected]':
resolution: {integrity: sha512-8yxF8+mMGGEbF8BUCp34HjdJj7CvTpGeZxBcpsDF6v7zPiGbJL1UDLzA8ZqYjmcngBHhPecbmeONTk/LiLAaEg==}
peerDependencies:
'@effect/platform': ^0.96.1
effect: ^3.21.2
'@emnapi/[email protected]':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
@@ -363,6 +380,36 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==}
cpu: [arm64]
os: [darwin]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==}
cpu: [x64]
os: [darwin]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==}
cpu: [arm64]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==}
cpu: [arm]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==}
cpu: [x64]
os: [linux]
'@msgpackr-extract/[email protected]':
resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==}
cpu: [x64]
os: [win32]
'@napi-rs/[email protected]':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
@@ -693,6 +740,9 @@ packages:
picomatch:
optional: true
[email protected]:
resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==}
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -825,11 +875,25 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==}
[email protected]:
resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==}
[email protected]:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
@@ -1177,6 +1241,19 @@ snapshots:
'@csstools/[email protected]': {}
'@effect/[email protected]([email protected])':
dependencies:
effect: 3.21.4
find-my-way-ts: 0.1.6
msgpackr: 1.12.1
multipasta: 0.2.7
'@effect/[email protected](@effect/[email protected]([email protected]))([email protected])':
dependencies:
'@effect/platform': 0.96.2([email protected])
effect: 3.21.4
msgpackr: 1.12.1
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@@ -1285,6 +1362,24 @@ snapshots:
'@jridgewell/[email protected]': {}
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@msgpackr-extract/[email protected]':
optional: true
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
dependencies:
'@emnapi/core': 1.11.1
@@ -1578,6 +1673,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
[email protected]: {}
[email protected]:
optional: true
@@ -1686,8 +1783,31 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
node-gyp-build-optional-packages: 5.2.2
optionalDependencies:
'@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4
'@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4
'@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4
optional: true
[email protected]:
optionalDependencies:
msgpackr-extract: 3.0.4
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
detect-libc: 2.1.2
optional: true
[email protected]: {}
[email protected]:
+1
View File
@@ -3,5 +3,6 @@ packages:
- packages/*
allowBuilds:
esbuild: true
msgpackr-extract: set this to true or false
minimumReleaseAgeExclude:
- '@types/[email protected]'