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
+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;
}