import { FetchHttpClient } from "@effect/platform"; import { RpcClient, RpcSerialization } from "@effect/rpc"; import { Cause, Clock, Context, Effect, Layer, Option, Ref, Schema, } from "effect"; import { InvalidTargetError, RpcDecodeError, type RpcExchangeEvidence, RpcProtocolError, RpcRemoteError, UnknownOperationError, UpstreamConnectionError, UpstreamResponseTooLargeError, UpstreamTimeoutError, } from "./errors.js"; import { type EvidenceRecord, withEvidenceCapture } from "./evidence.js"; import { getOperationMeta, isOperationName, type OperationName, } from "./method-registry.js"; import { WorkflowHealthPayloadSchema, WorkflowRpcs, WorkflowSourcesListPayloadSchema, WorkflowCapabilitiesListPayloadSchema, WorkflowCapabilitiesInspectPayloadSchema, WorkflowCapabilitiesCallPayloadSchema, WorkflowDraftWorkspacesListPayloadSchema, WorkflowDraftWorkspacesGetPayloadSchema, WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, WorkflowDraftWorkspacesCreateEmptyPayloadSchema, WorkflowDraftWorkspacesCreateFromCapabilityPayloadSchema, WorkflowDraftWorkspacesAddStepFromCapabilityPayloadSchema, WorkflowDraftWorkspacesUpdateCapabilityStepPayloadSchema, WorkflowDraftWorkspacesSetRoutePayloadSchema, WorkflowDraftWorkspacesSetContractPayloadSchema, WorkflowDraftWorkspacesSetStartPayloadSchema, WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, WorkflowDraftWorkspacesValidatePayloadSchema, WorkflowArtifactsListPayloadSchema, WorkflowArtifactsInspectPayloadSchema, WorkflowDeploymentsListPayloadSchema, WorkflowDeploymentsInspectPayloadSchema, WorkflowDeploymentsValidatePayloadSchema, WorkflowRunsListPayloadSchema, WorkflowRunsInspectPayloadSchema, WorkflowRunsStartPayloadSchema, WorkflowRunsResumePayloadSchema, WorkflowRunsTracePayloadSchema, } from "./rpcs.js"; import { normalizeLoopbackTarget } from "./target-policy.js"; const DEFAULT_TIMEOUT_MILLISECONDS = 5_000; const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; export interface WorkflowRpcOptions { readonly fetch?: typeof globalThis.fetch; readonly timeoutMilliseconds?: number; readonly maxResponseBytes?: number; } export interface OperationExchange { readonly operation: OperationName; readonly target: string; readonly label: string; readonly interpreted: unknown; readonly exchange: RpcExchangeEvidence; readonly equivalentCli: string; readonly durationMs: number; } export type WorkflowRpcError = | InvalidTargetError | UnknownOperationError | UpstreamConnectionError | UpstreamTimeoutError | UpstreamResponseTooLargeError | RpcProtocolError | RpcRemoteError | RpcDecodeError; const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({ request: evidence?.request.body ?? null, response: evidence?.response?.body ?? null, }); 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, 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; }; const mapCauseToError = ( cause: Cause.Cause, evidence: EvidenceRecord | null, ): WorkflowRpcError => { 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, }); } 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, }); } 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)); const lowerDescription = description.toLowerCase(); if ( lowerDescription.includes("parse") || lowerDescription.includes("schema") || lowerDescription.includes("decode") || lowerDescription.includes("expected") ) { 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 = ( schema: Schema.Schema, params: unknown, ): Effect.Effect => Schema.decodeUnknown(schema, { onExcessProperty: "error" })(params).pipe( Effect.mapError( () => new RpcDecodeError({ message: "invalid workflow RPC parameters" }), ), ); const unreachableOperation = (operation: never): Effect.Effect => Effect.dieMessage( `operation guard admitted an undispatchable method: ${operation}`, ); const decodeOperationMetadata = ( metadata: NonNullable>, result: unknown, params: unknown, evidence: EvidenceRecord | null, ): Effect.Effect< { readonly interpreted: unknown; readonly equivalentCli: string }, RpcDecodeError > => Effect.try({ try: () => ({ interpreted: metadata.interpret(result), equivalentCli: metadata.equivalentCli(params), }), catch: () => new RpcDecodeError({ message: "workflow RPC result did not match the expected schema", exchange: toExchange(evidence), }), }); const executeImpl = (options: WorkflowRpcOptions) => ( operation: string, target: string, params: unknown, ): Effect.Effect => { 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(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); } case "workflow.capabilities.list": { const payload = yield* decodeParams( WorkflowCapabilitiesListPayloadSchema, params, ); return yield* client.workflow["capabilities.list"](payload); } case "workflow.capabilities.inspect": { const payload = yield* decodeParams( WorkflowCapabilitiesInspectPayloadSchema, params, ); return yield* client.workflow["capabilities.inspect"](payload); } case "workflow.capabilities.call": { const payload = yield* decodeParams( WorkflowCapabilitiesCallPayloadSchema, params, ); return yield* client.workflow["capabilities.call"](payload); } case "workflow.draft_workspaces.list": { const payload = yield* decodeParams( WorkflowDraftWorkspacesListPayloadSchema, params, ); return yield* client.workflow["draft_workspaces.list"](payload); } case "workflow.draft_workspaces.get": { const payload = yield* decodeParams( WorkflowDraftWorkspacesGetPayloadSchema, params, ); return yield* client.workflow["draft_workspaces.get"](payload); } case "workflow.draft_workspaces.inspect_authoring_contract": { const payload = yield* decodeParams( WorkflowDraftWorkspacesInspectAuthoringContractPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.inspect_authoring_contract" ](payload); } case "workflow.draft_workspaces.create_empty": { const payload = yield* decodeParams( WorkflowDraftWorkspacesCreateEmptyPayloadSchema, params, ); return yield* client.workflow["draft_workspaces.create_empty"](payload); } case "workflow.draft_workspaces.create_from_capability": { const payload = yield* decodeParams( WorkflowDraftWorkspacesCreateFromCapabilityPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.create_from_capability" ](payload); } case "workflow.draft_workspaces.add_step_from_capability": { const payload = yield* decodeParams( WorkflowDraftWorkspacesAddStepFromCapabilityPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.add_step_from_capability" ](payload); } case "workflow.draft_workspaces.update_capability_step": { const payload = yield* decodeParams( WorkflowDraftWorkspacesUpdateCapabilityStepPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.update_capability_step" ](payload); } case "workflow.draft_workspaces.set_route": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetRoutePayloadSchema, params, ); return yield* client.workflow["draft_workspaces.set_route"](payload); } case "workflow.draft_workspaces.set_contract": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetContractPayloadSchema, params, ); return yield* client.workflow["draft_workspaces.set_contract"](payload); } case "workflow.draft_workspaces.set_start": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetStartPayloadSchema, params, ); return yield* client.workflow["draft_workspaces.set_start"](payload); } case "workflow.draft_workspaces.set_step_input_bindings": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetStepInputBindingsPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.set_step_input_bindings" ](payload); } case "workflow.draft_workspaces.set_step_output_bindings": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetStepOutputBindingsPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.set_step_output_bindings" ](payload); } case "workflow.draft_workspaces.set_workflow_output_bindings": { const payload = yield* decodeParams( WorkflowDraftWorkspacesSetWorkflowOutputBindingsPayloadSchema, params, ); return yield* client.workflow[ "draft_workspaces.set_workflow_output_bindings" ](payload); } case "workflow.draft_workspaces.validate": { const payload = yield* decodeParams( WorkflowDraftWorkspacesValidatePayloadSchema, params, ); return yield* client.workflow["draft_workspaces.validate"](payload); } case "workflow.artifacts.list": { const payload = yield* decodeParams( WorkflowArtifactsListPayloadSchema, params, ); return yield* client.workflow["artifacts.list"](payload); } case "workflow.artifacts.inspect": { const payload = yield* decodeParams( WorkflowArtifactsInspectPayloadSchema, params, ); return yield* client.workflow["artifacts.inspect"](payload); } case "workflow.deployments.list": { const payload = yield* decodeParams( WorkflowDeploymentsListPayloadSchema, params, ); return yield* client.workflow["deployments.list"](payload); } case "workflow.deployments.inspect": { const payload = yield* decodeParams( WorkflowDeploymentsInspectPayloadSchema, params, ); return yield* client.workflow["deployments.inspect"](payload); } case "workflow.deployments.validate": { const payload = yield* decodeParams( WorkflowDeploymentsValidatePayloadSchema, params, ); return yield* client.workflow["deployments.validate"](payload); } case "workflow.runs.list": { const payload = yield* decodeParams( WorkflowRunsListPayloadSchema, params, ); return yield* client.workflow["runs.list"](payload); } case "workflow.runs.inspect": { const payload = yield* decodeParams( WorkflowRunsInspectPayloadSchema, params, ); return yield* client.workflow["runs.inspect"](payload); } case "workflow.runs.start": { const payload = yield* decodeParams( WorkflowRunsStartPayloadSchema, params, ); return yield* client.workflow["runs.start"](payload); } case "workflow.runs.resume": { const payload = yield* decodeParams( WorkflowRunsResumePayloadSchema, params, ); return yield* client.workflow["runs.resume"](payload); } case "workflow.runs.trace": { const payload = yield* decodeParams( WorkflowRunsTracePayloadSchema, params, ); return yield* client.workflow["runs.trace"](payload); } } return yield* unreachableOperation(operation); }).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 decodedMetadata = yield* decodeOperationMetadata( metadata, result, params, evidence, ); const finishedAt = yield* Clock.currentTimeMillis; return { operation, target: normalizedTarget, label: metadata.label, interpreted: decodedMetadata.interpreted, exchange: toExchange(evidence), equivalentCli: decodedMetadata.equivalentCli, durationMs: finishedAt - startedAt, }; }); }; export const WorkflowRpc = Context.GenericTag<{ readonly execute: ( operation: string, target: string, params: unknown, ) => Effect.Effect; }>("WorkflowRpc"); export const makeWorkflowRpcLayer = (options: WorkflowRpcOptions = {}) => Layer.succeed(WorkflowRpc, { execute: executeImpl(options) });