fix: harden workflow console rpc boundary
This commit is contained in:
+245
-221
@@ -1,24 +1,52 @@
|
||||
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 { FetchHttpClient } from "@effect/platform";
|
||||
import { RpcClient, RpcSerialization } from "@effect/rpc";
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
Context,
|
||||
Effect,
|
||||
Layer,
|
||||
Option,
|
||||
Ref,
|
||||
Schema,
|
||||
} from "effect";
|
||||
import {
|
||||
InvalidTargetError,
|
||||
UnknownOperationError,
|
||||
UpstreamConnectionError,
|
||||
UpstreamTimeoutError,
|
||||
RpcDecodeError,
|
||||
type RpcExchangeEvidence,
|
||||
RpcProtocolError,
|
||||
RpcRemoteError,
|
||||
UnknownOperationError,
|
||||
UpstreamConnectionError,
|
||||
UpstreamResponseTooLargeError,
|
||||
UpstreamTimeoutError,
|
||||
} from "./errors.js";
|
||||
import { type EvidenceRecord, withEvidenceCapture } from "./evidence.js";
|
||||
import { getOperationMeta } from "./method-registry.js";
|
||||
import {
|
||||
WorkflowHealthPayloadSchema,
|
||||
WorkflowRpcs,
|
||||
WorkflowSourcesListPayloadSchema,
|
||||
} from "./rpcs.js";
|
||||
import { normalizeLoopbackTarget } from "./target-policy.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export type OperationName = "workflow.health" | "workflow.sources.list";
|
||||
|
||||
export interface WorkflowRpcOptions {
|
||||
readonly fetch?: typeof globalThis.fetch;
|
||||
readonly timeoutMilliseconds?: number;
|
||||
readonly maxResponseBytes?: number;
|
||||
}
|
||||
|
||||
export interface OperationExchange {
|
||||
readonly operation: string;
|
||||
readonly operation: OperationName;
|
||||
readonly target: string;
|
||||
readonly label: string;
|
||||
readonly interpreted: unknown;
|
||||
readonly exchange: { readonly request: unknown; readonly response: unknown };
|
||||
readonly exchange: RpcExchangeEvidence;
|
||||
readonly equivalentCli: string;
|
||||
readonly durationMs: number;
|
||||
}
|
||||
@@ -28,81 +56,224 @@ export type WorkflowRpcError =
|
||||
| UnknownOperationError
|
||||
| UpstreamConnectionError
|
||||
| UpstreamTimeoutError
|
||||
| UpstreamResponseTooLargeError
|
||||
| RpcProtocolError
|
||||
| RpcRemoteError;
|
||||
| RpcRemoteError
|
||||
| RpcDecodeError;
|
||||
|
||||
export type OperationName = "workflow.health" | "workflow.sources.list";
|
||||
const isOperationName = (value: string): value is OperationName =>
|
||||
value === "workflow.health" || value === "workflow.sources.list";
|
||||
|
||||
const isOperationName = (s: string): s is OperationName =>
|
||||
s === "workflow.health" || s === "workflow.sources.list";
|
||||
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
|
||||
request: evidence?.request.body ?? null,
|
||||
response: evidence?.response?.body ?? null,
|
||||
});
|
||||
|
||||
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;
|
||||
const responseError = (
|
||||
evidence: EvidenceRecord | null,
|
||||
): { readonly code: number; readonly message: string; readonly data?: unknown } | null => {
|
||||
const body = evidence?.response?.body;
|
||||
if (typeof body !== "object" || body === null || !("error" in body)) return null;
|
||||
const error = body.error;
|
||||
if (typeof error !== "object" || error === null) return null;
|
||||
const code = "code" in error ? Number(error.code) : Number.NaN;
|
||||
const message = "message" in error ? String(error.message) : "remote error";
|
||||
if (!Number.isFinite(code)) return null;
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
...("data" in error ? { data: error.data } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const containsTag = (
|
||||
value: unknown,
|
||||
tags: ReadonlySet<string>,
|
||||
depth = 0,
|
||||
): boolean => {
|
||||
if (depth > 6 || typeof value !== "object" || value === null) return false;
|
||||
if ("_tag" in value && typeof value._tag === "string" && tags.has(value._tag)) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(value).some((child) => containsTag(child, tags, depth + 1));
|
||||
};
|
||||
|
||||
const domainErrorFromUnknown = (
|
||||
value: unknown,
|
||||
exchange: RpcExchangeEvidence,
|
||||
): WorkflowRpcError | null => {
|
||||
if (value instanceof UpstreamTimeoutError) {
|
||||
return new UpstreamTimeoutError({ message: value.message, exchange });
|
||||
}
|
||||
if (value instanceof UpstreamResponseTooLargeError) {
|
||||
return new UpstreamResponseTooLargeError({ message: value.message, exchange });
|
||||
}
|
||||
if (value instanceof RpcProtocolError) {
|
||||
return new RpcProtocolError({ message: value.message, exchange });
|
||||
}
|
||||
if (value instanceof RpcDecodeError) {
|
||||
return new RpcDecodeError({ message: value.message, exchange });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
cause: Cause.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 });
|
||||
}
|
||||
const exchange = toExchange(evidence);
|
||||
const remote = responseError(evidence);
|
||||
if (remote) {
|
||||
return new RpcRemoteError({
|
||||
message: remote.message,
|
||||
code: remote.code,
|
||||
...(remote.data === undefined ? {} : { data: JSON.stringify(remote.data) }),
|
||||
exchange,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 failure = Option.getOrUndefined(Cause.failureOption(cause));
|
||||
const defect = Option.getOrUndefined(Cause.dieOption(cause));
|
||||
const known =
|
||||
domainErrorFromUnknown(failure, exchange) ??
|
||||
domainErrorFromUnknown(defect, exchange);
|
||||
if (known) return known;
|
||||
|
||||
if (evidence?.request && !evidence.response) {
|
||||
return new UpstreamConnectionError({
|
||||
message: "could not connect to the workflow RPC server",
|
||||
exchange,
|
||||
});
|
||||
}
|
||||
|
||||
const msg = cause instanceof Error ? cause.message : String(cause);
|
||||
return new RpcProtocolError({ message: msg });
|
||||
if (containsTag(cause, new Set(["RequestError", "ResponseError"]))) {
|
||||
return new UpstreamConnectionError({
|
||||
message: "could not connect to the workflow RPC server",
|
||||
exchange,
|
||||
});
|
||||
}
|
||||
|
||||
const description = String(Cause.squash(cause));
|
||||
if (description.toLowerCase().includes("parse") || description.includes("Schema")) {
|
||||
return new RpcDecodeError({
|
||||
message: "workflow RPC result did not match the expected schema",
|
||||
exchange,
|
||||
});
|
||||
}
|
||||
return new RpcProtocolError({
|
||||
message: "workflow RPC server returned an invalid response",
|
||||
exchange,
|
||||
});
|
||||
};
|
||||
|
||||
const decodeParams = <A, I>(
|
||||
schema: Schema.Schema<A, I>,
|
||||
params: unknown,
|
||||
): Effect.Effect<A, RpcDecodeError> =>
|
||||
Schema.decodeUnknown(schema, { onExcessProperty: "error" })(params).pipe(
|
||||
Effect.mapError(
|
||||
() => new RpcDecodeError({ message: "invalid workflow RPC parameters" }),
|
||||
),
|
||||
);
|
||||
|
||||
const executeImpl =
|
||||
(options: WorkflowRpcOptions) =>
|
||||
(
|
||||
operation: string,
|
||||
target: string,
|
||||
params: unknown,
|
||||
): Effect.Effect<OperationExchange, WorkflowRpcError> => {
|
||||
let normalizedTarget: string;
|
||||
try {
|
||||
normalizedTarget = normalizeLoopbackTarget(target);
|
||||
} catch (error) {
|
||||
return Effect.fail(
|
||||
error instanceof InvalidTargetError
|
||||
? error
|
||||
: new InvalidTargetError({ message: "invalid workflow RPC target" }),
|
||||
);
|
||||
}
|
||||
if (!isOperationName(operation)) {
|
||||
return Effect.fail(
|
||||
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
||||
);
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const evidenceRef = yield* Ref.make<EvidenceRecord | null>(null);
|
||||
const startedAt = yield* Clock.currentTimeMillis;
|
||||
const timeoutMilliseconds =
|
||||
options.timeoutMilliseconds ?? DEFAULT_TIMEOUT_MILLISECONDS;
|
||||
const maxResponseBytes =
|
||||
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
||||
|
||||
const fetchOptionsLayer = Layer.mergeAll(
|
||||
Layer.succeed(FetchHttpClient.Fetch, options.fetch ?? globalThis.fetch),
|
||||
Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" }),
|
||||
);
|
||||
const fetchLayer = Layer.merge(
|
||||
FetchHttpClient.layer.pipe(Layer.provide(fetchOptionsLayer)),
|
||||
RpcSerialization.layerJsonRpc(),
|
||||
);
|
||||
const protocolLayer = RpcClient.layerProtocolHttp({
|
||||
url: normalizedTarget,
|
||||
transformClient: (client) =>
|
||||
withEvidenceCapture(client, evidenceRef, maxResponseBytes),
|
||||
}).pipe(Layer.provide(fetchLayer));
|
||||
|
||||
const call = Effect.gen(function* () {
|
||||
const client = yield* RpcClient.make(WorkflowRpcs);
|
||||
switch (operation) {
|
||||
case "workflow.health": {
|
||||
const payload = yield* decodeParams(WorkflowHealthPayloadSchema, params);
|
||||
return yield* client.workflow.health(payload);
|
||||
}
|
||||
case "workflow.sources.list": {
|
||||
const payload = yield* decodeParams(
|
||||
WorkflowSourcesListPayloadSchema,
|
||||
params,
|
||||
);
|
||||
return yield* client.workflow["sources.list"](payload);
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
Effect.provide(protocolLayer),
|
||||
Effect.scoped,
|
||||
Effect.timeoutFail({
|
||||
duration: timeoutMilliseconds,
|
||||
onTimeout: () =>
|
||||
new UpstreamTimeoutError({
|
||||
message: "workflow RPC request timed out",
|
||||
}),
|
||||
}),
|
||||
Effect.catchAllCause((cause) =>
|
||||
Ref.get(evidenceRef).pipe(
|
||||
Effect.flatMap((evidence) => Effect.fail(mapCauseToError(cause, evidence))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = yield* call;
|
||||
const evidence = yield* Ref.get(evidenceRef);
|
||||
const metadata = getOperationMeta(operation);
|
||||
if (!metadata) {
|
||||
return yield* Effect.fail(
|
||||
new UnknownOperationError({ message: `unknown operation: ${operation}` }),
|
||||
);
|
||||
}
|
||||
const finishedAt = yield* Clock.currentTimeMillis;
|
||||
return {
|
||||
operation,
|
||||
target: normalizedTarget,
|
||||
label: metadata.label,
|
||||
interpreted: metadata.interpret(result),
|
||||
exchange: toExchange(evidence),
|
||||
equivalentCli: metadata.equivalentCli(params),
|
||||
durationMs: finishedAt - startedAt,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const WorkflowRpc = Context.GenericTag<{
|
||||
readonly execute: (
|
||||
operation: string,
|
||||
@@ -111,152 +282,5 @@ export const WorkflowRpc = Context.GenericTag<{
|
||||
) => 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,
|
||||
});
|
||||
export const makeWorkflowRpcLayer = (options: WorkflowRpcOptions = {}) =>
|
||||
Layer.succeed(WorkflowRpc, { execute: executeImpl(options) });
|
||||
|
||||
Reference in New Issue
Block a user