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()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// Server must listen on this port (set via WEB_PORT env or default 8787)
|
||||||
"/api": "http://127.0.0.1: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,
|
"composite": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../../packages/rpc" }]
|
"references": [{ "path": "../../packages/rpc" }]
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/platform": "0.96.2",
|
||||||
|
"@effect/rpc": "0.75.1",
|
||||||
"effect": "3.21.4"
|
"effect": "3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { normalizeLoopbackTarget } from "./target-policy.js";
|
||||||
|
|
||||||
export { decodeRpcResponse } from "./protocol.js";
|
export { WorkflowHealth, WorkflowSourcesList, WorkflowRpcs } from "./rpcs.js";
|
||||||
|
|
||||||
export type {
|
export { WorkflowRpc, makeWorkflowRpcLayer } from "./service.js";
|
||||||
JsonRpcRequest,
|
export type { OperationExchange, WorkflowRpcError, OperationName } from "./service.js";
|
||||||
JsonRpcResponse,
|
|
||||||
JsonRpcSuccess,
|
export {
|
||||||
JsonRpcFailure,
|
getOperationMeta,
|
||||||
} from "./protocol.js";
|
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:
|
packages/rpc:
|
||||||
dependencies:
|
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:
|
effect:
|
||||||
specifier: 3.21.4
|
specifier: 3.21.4
|
||||||
version: 3.21.4
|
version: 3.21.4
|
||||||
@@ -171,6 +177,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
engines: {node: '>=20.19.0'}
|
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]':
|
'@emnapi/[email protected]':
|
||||||
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
|
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
|
||||||
|
|
||||||
@@ -363,6 +380,36 @@ packages:
|
|||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
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]':
|
'@napi-rs/[email protected]':
|
||||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -693,6 +740,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -825,11 +875,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -1177,6 +1241,19 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/[email protected]': {}
|
'@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]':
|
'@emnapi/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.2
|
'@emnapi/wasi-threads': 1.2.2
|
||||||
@@ -1285,6 +1362,24 @@ snapshots:
|
|||||||
|
|
||||||
'@jridgewell/[email protected]': {}
|
'@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])':
|
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.11.1
|
'@emnapi/core': 1.11.1
|
||||||
@@ -1578,6 +1673,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1686,8 +1783,31 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optional: true
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ packages:
|
|||||||
- packages/*
|
- packages/*
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
msgpackr-extract: set this to true or false
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@types/[email protected]'
|
- '@types/[email protected]'
|
||||||
|
|||||||
Reference in New Issue
Block a user