555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
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<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;
|
|
};
|
|
|
|
const mapCauseToError = (
|
|
cause: Cause.Cause<unknown>,
|
|
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 = <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 unreachableOperation = (operation: never): Effect.Effect<never> =>
|
|
Effect.dieMessage(
|
|
`operation guard admitted an undispatchable method: ${operation}`,
|
|
);
|
|
|
|
const decodeOperationMetadata = (
|
|
metadata: NonNullable<ReturnType<typeof getOperationMeta>>,
|
|
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<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);
|
|
}
|
|
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<OperationExchange, WorkflowRpcError>;
|
|
}>("WorkflowRpc");
|
|
|
|
export const makeWorkflowRpcLayer = (options: WorkflowRpcOptions = {}) =>
|
|
Layer.succeed(WorkflowRpc, { execute: executeImpl(options) });
|