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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user