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:
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../../packages/rpc" }]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
Generated
+120
@@ -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]:
|
||||
|
||||
@@ -3,5 +3,6 @@ packages:
|
||||
- packages/*
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
msgpackr-extract: set this to true or false
|
||||
minimumReleaseAgeExclude:
|
||||
- '@types/[email protected]'
|
||||
|
||||
Reference in New Issue
Block a user