feat: add console read clients

This commit is contained in:
lda
2026-08-04 19:57:29 +07:00 Verified
parent 229dd0e01a
commit fcb96f0f62
14 changed files with 1098 additions and 0 deletions
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import {
decodeCapabilityDetail,
decodeCapabilityPage,
} from "./capability-models.js";
import { createCapabilityClient } from "./capability-client.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
const executor = () =>
({ run: vi.fn().mockResolvedValue({}) }) as unknown as ConsoleReadExecutor;
describe("CapabilityClient", () => {
it("lowers list filters to the capability operation payload", async () => {
const readExecutor = executor();
const client = createCapabilityClient(readExecutor);
await client.list({
query: "document",
sourceId: "local.lda_docs",
limit: 50,
});
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.capabilities.list",
{ query: "document", source_id: "local.lda_docs", limit: 50 },
decodeCapabilityPage,
);
});
it("lowers a qualified capability name for inspection", async () => {
const readExecutor = executor();
const client = createCapabilityClient(readExecutor);
await client.inspect("local.lda_docs.read_documents");
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.capabilities.inspect",
{ qualified_name: "local.lda_docs.read_documents" },
decodeCapabilityDetail,
);
});
it("rejects blank capability names before invoking the executor", async () => {
const readExecutor = executor();
const client = createCapabilityClient(readExecutor);
await expect(client.inspect(" ")).rejects.toMatchObject({
kind: "operation",
operation: "workflow.capabilities.inspect",
});
expect(readExecutor.run).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import type { OperationName } from "../../connection/contracts.js";
import {
decodeCapabilityDetail,
decodeCapabilityPage,
type CapabilityDetail,
type CapabilityPage,
} from "./capability-models.js";
import { ConsoleClientError } from "./errors.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
export interface CapabilityClient {
list(input: {
readonly query?: string;
readonly sourceId?: string;
readonly cursor?: string;
readonly limit?: number;
}): Promise<CapabilityPage>;
inspect(qualifiedName: string): Promise<CapabilityDetail>;
}
const invalidInput = (operation: OperationName, message: string): ConsoleClientError =>
new ConsoleClientError("operation", operation, message);
export const createCapabilityClient = (
executor: ConsoleReadExecutor,
): CapabilityClient => ({
list: (input) => {
const params: Record<string, string | number> = {};
if (input.query !== undefined) params.query = input.query;
if (input.sourceId !== undefined) params.source_id = input.sourceId;
if (input.cursor !== undefined) params.cursor = input.cursor;
if (input.limit !== undefined) params.limit = input.limit;
return executor.run(
"workflow.capabilities.list",
params,
decodeCapabilityPage,
);
},
inspect: (qualifiedName) => {
if (!qualifiedName.trim()) {
return Promise.reject(
invalidInput(
"workflow.capabilities.inspect",
"qualified capability name must not be blank",
),
);
}
return executor.run(
"workflow.capabilities.inspect",
{ qualified_name: qualifiedName },
decodeCapabilityDetail,
);
},
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
decodeCapabilityDetail,
decodeCapabilityPage,
} from "./capability-models.js";
const wrapperHints = {
confidence: "high",
notes: [],
};
describe("capability models", () => {
it("preserves discriminated node and wrapper summaries", () => {
const page = decodeCapabilityPage({
items: [
{
kind: "node_spec",
name: "local.docs.read",
sourceId: "local.docs",
description: null,
outcomes: ["ok", "error"],
inputFields: ["names"],
outputFields: ["documents"],
},
{
kind: "wrapper_artifact",
name: "local.reports.build",
sourceId: "local.reports",
description: "Build a report.",
outcomes: ["ok"],
inputFields: [],
outputFields: ["report"],
},
],
nextCursor: null,
total: 2,
});
expect(page.items[0]?.kind).toBe("node_spec");
expect(page.items[1]?.kind).toBe("wrapper_artifact");
expect(page.items[0]?.description).toBeNull();
});
it("decodes nullable detail fields for both capability kinds", () => {
const node = decodeCapabilityDetail({
kind: "node_spec",
name: "local.docs.read",
sourceId: "local.docs",
description: null,
isAsync: false,
outcomes: ["ok"],
inputSchema: {},
outputSchema: {},
wrapperHints,
acceptsContext: false,
});
const wrapper = decodeCapabilityDetail({
kind: "wrapper_artifact",
name: "local.reports.build",
sourceId: "local.reports",
description: null,
isAsync: true,
outcomes: ["ok"],
inputSchema: {},
outputSchema: {},
wrapperHints,
artifactId: "reports",
title: "Reports",
version: 2,
requiredCapabilities: {},
});
expect(node.kind).toBe("node_spec");
expect(wrapper.kind).toBe("wrapper_artifact");
if (wrapper.kind === "wrapper_artifact") {
expect(wrapper.version).toBe(2);
}
});
});
@@ -0,0 +1,83 @@
import * as v from "valibot";
const decode = <T>(
label: string,
schema: v.GenericSchema<unknown, T>,
value: unknown,
): T => {
const result = v.safeParse(schema, value);
if (result.success) return result.output;
throw new Error(
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
);
};
const JsonObjectSchema = v.record(v.string(), v.unknown());
const WrapperHintsSchema = v.record(v.string(), v.unknown());
const CapabilitySummarySchema = v.variant("kind", [
v.object({
kind: v.literal("node_spec"),
name: v.string(),
sourceId: v.string(),
description: v.nullish(v.string(), null),
outcomes: v.array(v.string()),
inputFields: v.array(v.string()),
outputFields: v.array(v.string()),
}),
v.object({
kind: v.literal("wrapper_artifact"),
name: v.string(),
sourceId: v.string(),
description: v.nullish(v.string(), null),
outcomes: v.array(v.string()),
inputFields: v.array(v.string()),
outputFields: v.array(v.string()),
}),
]);
const CapabilityPageSchema = v.object({
items: v.array(CapabilitySummarySchema),
nextCursor: v.nullish(v.string(), null),
total: v.number(),
});
const CapabilityDetailSchema = v.variant("kind", [
v.object({
kind: v.literal("node_spec"),
name: v.string(),
sourceId: v.string(),
description: v.nullish(v.string(), null),
isAsync: v.boolean(),
outcomes: v.array(v.string()),
inputSchema: JsonObjectSchema,
outputSchema: JsonObjectSchema,
wrapperHints: WrapperHintsSchema,
acceptsContext: v.boolean(),
}),
v.object({
kind: v.literal("wrapper_artifact"),
name: v.string(),
sourceId: v.string(),
description: v.nullish(v.string(), null),
isAsync: v.boolean(),
outcomes: v.array(v.string()),
inputSchema: JsonObjectSchema,
outputSchema: JsonObjectSchema,
wrapperHints: WrapperHintsSchema,
artifactId: v.string(),
title: v.string(),
version: v.number(),
requiredCapabilities: v.record(v.string(), v.unknown()),
}),
]);
export type CapabilitySummary = v.InferOutput<typeof CapabilitySummarySchema>;
export type CapabilityPage = v.InferOutput<typeof CapabilityPageSchema>;
export type CapabilityDetail = v.InferOutput<typeof CapabilityDetailSchema>;
export const decodeCapabilityPage = (value: unknown): CapabilityPage =>
decode("CapabilityPage", CapabilityPageSchema, value);
export const decodeCapabilityDetail = (value: unknown): CapabilityDetail =>
decode("CapabilityDetail", CapabilityDetailSchema, value);
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import {
decodeDraftWorkspace,
decodeDraftWorkspacePage,
} from "./draft-workspace-models.js";
import { createDraftWorkspaceClient } from "./draft-workspace-client.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
const executor = () =>
({ run: vi.fn().mockResolvedValue({}) }) as unknown as ConsoleReadExecutor;
describe("DraftWorkspaceClient", () => {
it("lists draft workspaces without a mutation payload", async () => {
const readExecutor = executor();
const client = createDraftWorkspaceClient(readExecutor);
await client.list();
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.draft_workspaces.list",
{},
decodeDraftWorkspacePage,
);
});
it("loads a draft with the full document enabled", async () => {
const readExecutor = executor();
const client = createDraftWorkspaceClient(readExecutor);
await client.load("draft-report");
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.draft_workspaces.get",
{ workspace_id: "draft-report", include_draft: true },
decodeDraftWorkspace,
);
});
it("rejects blank workspace identifiers before invoking the executor", async () => {
const readExecutor = executor();
const client = createDraftWorkspaceClient(readExecutor);
await expect(client.load(" \t")).rejects.toMatchObject({
kind: "operation",
operation: "workflow.draft_workspaces.get",
});
expect(readExecutor.run).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,44 @@
import type { OperationName } from "../../connection/contracts.js";
import {
decodeDraftWorkspace,
decodeDraftWorkspacePage,
type DraftWorkspace,
type DraftWorkspacePage,
} from "./draft-workspace-models.js";
import { ConsoleClientError } from "./errors.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
export interface DraftWorkspaceClient {
list(): Promise<DraftWorkspacePage>;
load(workspaceId: string): Promise<DraftWorkspace>;
}
const invalidInput = (operation: OperationName, message: string): ConsoleClientError =>
new ConsoleClientError("operation", operation, message);
export const createDraftWorkspaceClient = (
executor: ConsoleReadExecutor,
): DraftWorkspaceClient => ({
list: () =>
executor.run(
"workflow.draft_workspaces.list",
{},
decodeDraftWorkspacePage,
),
load: (workspaceId) => {
if (!workspaceId.trim()) {
return Promise.reject(
invalidInput(
"workflow.draft_workspaces.get",
"workspace id must not be blank",
),
);
}
return executor.run(
"workflow.draft_workspaces.get",
{ workspace_id: workspaceId, include_draft: true },
decodeDraftWorkspace,
);
},
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
decodeDraftWorkspace,
decodeDraftWorkspacePage,
} from "./draft-workspace-models.js";
const summary = {
name: { preserved: true },
start: ["opaque", 1],
stepCount: 1,
routeCount: 0,
steps: ["read"],
};
describe("draft workspace models", () => {
it("preserves opaque summary values and defaults an omitted draft", () => {
const workspace = decodeDraftWorkspace({
workspaceId: "draft-report",
revision: 3,
title: null,
status: "valid",
diagnostics: [],
summary,
});
expect(workspace.summary.name).toEqual({ preserved: true });
expect(workspace.summary.start).toEqual(["opaque", 1]);
expect(workspace.draft).toBeNull();
});
it("decodes a page with an optional draft document", () => {
const page = decodeDraftWorkspacePage({
items: [
{
workspaceId: "draft-report",
revision: 1,
title: "Report",
status: "invalid",
diagnostics: [],
summary,
draft: { nodes: [] },
},
],
});
expect(page.items[0]?.draft).toEqual({ nodes: [] });
});
it("rejects a malformed diagnostic", () => {
expect(() =>
decodeDraftWorkspace({
workspaceId: "draft-report",
revision: 1,
title: "Report",
status: "invalid",
diagnostics: [{ code: "missing-path" }],
summary,
}),
).toThrow(/DraftWorkspace is malformed/);
});
});
@@ -0,0 +1,62 @@
import * as v from "valibot";
const decode = <T>(
label: string,
schema: v.GenericSchema<unknown, T>,
value: unknown,
): T => {
const result = v.safeParse(schema, value);
if (result.success) return result.output;
throw new Error(
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
);
};
const DraftDiagnosticSchema = v.object({
code: v.string(),
path: v.string(),
message: v.string(),
stepId: v.nullish(v.string(), null),
repairHint: v.nullish(v.string(), null),
details: v.record(v.string(), v.unknown()),
});
const DraftWorkspaceSummarySchema = v.object({
name: v.unknown(),
start: v.unknown(),
stepCount: v.number(),
routeCount: v.number(),
steps: v.array(v.string()),
});
const DraftWorkspaceSchema = v.object({
workspaceId: v.string(),
revision: v.number(),
title: v.nullish(v.string(), null),
status: v.union([
v.literal("valid"),
v.literal("invalid"),
v.literal("conflict"),
]),
diagnostics: v.array(DraftDiagnosticSchema),
summary: DraftWorkspaceSummarySchema,
draft: v.optional(v.nullish(v.record(v.string(), v.unknown()), null), null),
});
const DraftWorkspacePageSchema = v.object({
items: v.array(DraftWorkspaceSchema),
});
export type DraftDiagnostic = v.InferOutput<typeof DraftDiagnosticSchema>;
export type DraftWorkspaceSummary = v.InferOutput<
typeof DraftWorkspaceSummarySchema
>;
export type DraftWorkspace = v.InferOutput<typeof DraftWorkspaceSchema>;
export type DraftWorkspacePage = v.InferOutput<typeof DraftWorkspacePageSchema>;
export const decodeDraftWorkspacePage = (
value: unknown,
): DraftWorkspacePage => decode("DraftWorkspacePage", DraftWorkspacePageSchema, value);
export const decodeDraftWorkspace = (value: unknown): DraftWorkspace =>
decode("DraftWorkspace", DraftWorkspaceSchema, value);
@@ -0,0 +1,22 @@
import type { OperationName } from "../../connection/contracts.js";
export type ConsoleClientErrorKind =
| "connection"
| "not_found"
| "permission"
| "decode"
| "transport"
| "operation";
export class ConsoleClientError extends Error {
override readonly name = "ConsoleClientError";
constructor(
readonly kind: ConsoleClientErrorKind,
readonly operation: OperationName,
message: string,
) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import {
decodeArtifactDetail,
decodeArtifactList,
decodeDeploymentDetail,
decodeDeploymentList,
decodeDeploymentValidation,
decodeRunDetail,
decodeRunList,
decodeTracePage,
} from "../../lifecycle/models.js";
import { createLifecycleClients } from "./lifecycle-clients.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
const executor = () =>
({ run: vi.fn().mockResolvedValue({}) }) as unknown as ConsoleReadExecutor;
describe("lifecycle clients", () => {
it("omits undefined artifact list parameters", async () => {
const readExecutor = executor();
const { artifacts } = createLifecycleClients(readExecutor);
await artifacts.list({ cursor: "artifact-next" });
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.artifacts.list",
{ cursor: "artifact-next" },
decodeArtifactList,
);
});
it("lowers lifecycle inspection and validation reads", async () => {
const readExecutor = executor();
const { artifacts, deployments, runs } = createLifecycleClients(readExecutor);
await artifacts.inspect("report", 2);
await deployments.inspect("report.default");
await deployments.validate("report.default");
await runs.inspect("run_123");
expect(readExecutor.run).toHaveBeenNthCalledWith(
1,
"workflow.artifacts.inspect",
{ artifact_id: "report", version: 2 },
decodeArtifactDetail,
);
expect(readExecutor.run).toHaveBeenNthCalledWith(
2,
"workflow.deployments.inspect",
{ deployment_id: "report.default" },
decodeDeploymentDetail,
);
expect(readExecutor.run).toHaveBeenNthCalledWith(
3,
"workflow.deployments.validate",
{ deployment_id: "report.default" },
decodeDeploymentValidation,
);
expect(readExecutor.run).toHaveBeenNthCalledWith(
4,
"workflow.runs.inspect",
{ run_id: "run_123" },
decodeRunDetail,
);
});
it("lowers an explicit run trace range", async () => {
const readExecutor = executor();
const { runs } = createLifecycleClients(readExecutor);
await runs.trace("run_123", 50, 50);
expect(readExecutor.run).toHaveBeenCalledWith(
"workflow.runs.trace",
{ run_id: "run_123", trace_range: { start: 50, limit: 50 } },
decodeTracePage,
);
});
it("rejects invalid lifecycle identifiers, versions, and ranges", async () => {
const readExecutor = executor();
const { artifacts, deployments, runs } = createLifecycleClients(readExecutor);
await expect(artifacts.inspect("", 1)).rejects.toMatchObject({ kind: "operation" });
await expect(artifacts.inspect("report", 0)).rejects.toMatchObject({ kind: "operation" });
await expect(deployments.inspect(" ")).rejects.toMatchObject({ kind: "operation" });
await expect(runs.trace("run_123", -1, 50)).rejects.toMatchObject({ kind: "operation" });
await expect(runs.trace("run_123", 0, 0)).rejects.toMatchObject({ kind: "operation" });
expect(readExecutor.run).not.toHaveBeenCalled();
});
it("omits undefined run list parameters", async () => {
const readExecutor = executor();
const { deployments, runs } = createLifecycleClients(readExecutor);
await deployments.list();
await runs.list({ limit: 10 });
expect(readExecutor.run).toHaveBeenNthCalledWith(
1,
"workflow.deployments.list",
{},
decodeDeploymentList,
);
expect(readExecutor.run).toHaveBeenNthCalledWith(
2,
"workflow.runs.list",
{ limit: 10 },
decodeRunList,
);
});
});
@@ -0,0 +1,164 @@
import type { OperationName } from "../../connection/contracts.js";
import {
decodeArtifactDetail,
decodeArtifactList,
decodeDeploymentDetail,
decodeDeploymentList,
decodeDeploymentValidation,
decodeRunDetail,
decodeRunList,
decodeTracePage,
type ArtifactDetail,
type ArtifactList,
type DeploymentDetail,
type DeploymentList,
type DeploymentValidation,
type RunDetail,
type RunList,
type TracePage,
} from "../../lifecycle/models.js";
import { ConsoleClientError } from "./errors.js";
import type { ConsoleReadExecutor } from "./read-executor.js";
export interface ArtifactClient {
list(input: {
readonly cursor?: string;
readonly limit?: number;
}): Promise<ArtifactList>;
inspect(artifactId: string, version: number): Promise<ArtifactDetail>;
}
export interface DeploymentClient {
list(): Promise<DeploymentList>;
inspect(deploymentId: string): Promise<DeploymentDetail>;
validate(deploymentId: string): Promise<DeploymentValidation>;
}
export interface RunClient {
list(input: {
readonly cursor?: string;
readonly limit?: number;
}): Promise<RunList>;
inspect(runId: string): Promise<RunDetail>;
trace(runId: string, start: number, limit: number): Promise<TracePage>;
}
const invalidInput = (
operation: OperationName,
message: string,
): ConsoleClientError =>
new ConsoleClientError("operation", operation, message);
const requireIdentifier = (
operation: OperationName,
value: string,
label: string,
): void => {
if (!value.trim()) throw invalidInput(operation, `${label} must not be blank`);
};
const requirePositiveInteger = (
operation: OperationName,
value: number,
label: string,
): void => {
if (!Number.isInteger(value) || value < 1) {
throw invalidInput(operation, `${label} must be a positive integer`);
}
};
const requireTraceRange = (
operation: OperationName,
start: number,
limit: number,
): void => {
if (!Number.isInteger(start) || start < 0) {
throw invalidInput(operation, "trace start must be a non-negative integer");
}
requirePositiveInteger(operation, limit, "trace limit");
};
export const createLifecycleClients = (executor: ConsoleReadExecutor): {
readonly artifacts: ArtifactClient;
readonly deployments: DeploymentClient;
readonly runs: RunClient;
} => {
const artifacts: ArtifactClient = {
list: (input) => {
const params: Record<string, string | number> = {};
if (input.cursor !== undefined) params.cursor = input.cursor;
if (input.limit !== undefined) params.limit = input.limit;
return executor.run("workflow.artifacts.list", params, decodeArtifactList);
},
inspect: async (artifactId, version) => {
requireIdentifier(
"workflow.artifacts.inspect",
artifactId,
"artifact id",
);
requirePositiveInteger("workflow.artifacts.inspect", version, "version");
return executor.run(
"workflow.artifacts.inspect",
{ artifact_id: artifactId, version },
decodeArtifactDetail,
);
},
};
const deployments: DeploymentClient = {
list: () =>
executor.run("workflow.deployments.list", {}, decodeDeploymentList),
inspect: async (deploymentId) => {
requireIdentifier(
"workflow.deployments.inspect",
deploymentId,
"deployment id",
);
return executor.run(
"workflow.deployments.inspect",
{ deployment_id: deploymentId },
decodeDeploymentDetail,
);
},
validate: async (deploymentId) => {
requireIdentifier(
"workflow.deployments.validate",
deploymentId,
"deployment id",
);
return executor.run(
"workflow.deployments.validate",
{ deployment_id: deploymentId },
decodeDeploymentValidation,
);
},
};
const runs: RunClient = {
list: (input) => {
const params: Record<string, string | number> = {};
if (input.cursor !== undefined) params.cursor = input.cursor;
if (input.limit !== undefined) params.limit = input.limit;
return executor.run("workflow.runs.list", params, decodeRunList);
},
inspect: async (runId) => {
requireIdentifier("workflow.runs.inspect", runId, "run id");
return executor.run(
"workflow.runs.inspect",
{ run_id: runId },
decodeRunDetail,
);
},
trace: async (runId, start, limit) => {
requireIdentifier("workflow.runs.trace", runId, "run id");
requireTraceRange("workflow.runs.trace", start, limit);
return executor.run(
"workflow.runs.trace",
{ run_id: runId, trace_range: { start, limit } },
decodeTracePage,
);
},
};
return { artifacts, deployments, runs };
};
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from "vitest";
import type { RpcResponse } from "../../connection/contracts.js";
import { createConsoleReadExecutor } from "./read-executor.js";
const success = (interpreted: unknown): RpcResponse => ({
ok: true,
operation: "workflow.capabilities.list",
label: "List capabilities",
interpreted,
exchange: { request: { sent: true }, response: { status: 200 } },
equivalentCli: "uv run wf cap list",
durationMs: 4,
});
const failure = (code: string): RpcResponse => ({
ok: false,
error: { code, message: "operation failed" },
exchange: { request: { sent: true }, response: { status: 502 } },
});
describe("ConsoleReadExecutor", () => {
it("records one receipt and returns decoded success data", async () => {
const invoke = vi.fn(async () => success({ value: 1 }));
const recordEvidence = vi.fn();
const executor = createConsoleReadExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke,
});
const result = await executor.run(
"workflow.capabilities.list",
{ limit: 1 },
(value) => ({ decoded: value }),
);
expect(result).toEqual({ decoded: { value: 1 } });
expect(invoke).toHaveBeenCalledWith(
"workflow.capabilities.list",
"http://console.test/rpc",
{ limit: 1 },
);
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.capabilities.list-0",
operation: "workflow.capabilities.list",
request: { sent: true },
response: { status: 200 },
});
});
it("records failed evidence and normalizes browser failures", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleReadExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => failure("upstream_unreachable")),
});
await expect(
executor.run("workflow.capabilities.list", {}, (value) => value),
).rejects.toMatchObject({
kind: "connection",
operation: "workflow.capabilities.list",
});
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.capabilities.list-0",
label: "workflow.capabilities.list failed",
response: { status: 502 },
});
});
it("turns decoder failures into decode errors", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleReadExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => success({ malformed: true })),
});
await expect(
executor.run("workflow.capabilities.list", {}, () => {
throw new Error("invalid capability page");
}),
).rejects.toMatchObject({ kind: "decode" });
expect(recordEvidence).toHaveBeenCalledTimes(1);
});
it("maps rejected invocations to transport errors", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleReadExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => {
throw new Error("fetch failed");
}),
});
await expect(
executor.run("workflow.capabilities.list", {}, (value) => value),
).rejects.toMatchObject({ kind: "transport" });
expect(recordEvidence).toHaveBeenCalledTimes(1);
});
it("keeps evidence ids unique across consecutive reads", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleReadExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => success(null)),
});
await executor.run("workflow.capabilities.list", {}, (value) => value);
await executor.run("workflow.capabilities.list", {}, (value) => value);
const ids = recordEvidence.mock.calls.map(([record]) => record.id);
expect(new Set(ids).size).toBe(2);
});
});
@@ -0,0 +1,128 @@
import { callOperation } from "../../connection/api.js";
import type {
OperationName,
RpcResponse,
} from "../../connection/contracts.js";
import type { EvidenceRecord } from "../../app/state.js";
import { ConsoleClientError, type ConsoleClientErrorKind } from "./errors.js";
export interface ConsoleReadExecutor {
run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T>;
}
type InvokeOperation = (
operation: OperationName,
target: string,
params: unknown,
) => Promise<RpcResponse>;
const errorKindForCode = (code: string): ConsoleClientErrorKind => {
switch (code) {
case "invalid_target":
case "upstream_unreachable":
return "connection";
case "unknown_operation":
return "permission";
case "rpc_decode_error":
return "decode";
default:
return "operation";
}
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export const createConsoleReadExecutor = (options: {
readonly target: string;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly invoke?: InvokeOperation;
}): ConsoleReadExecutor => {
let evidenceSequence = 0;
const invoke = options.invoke ?? callOperation;
const record = (
operation: OperationName,
label: string,
equivalentCli: string,
request: unknown,
response: unknown,
durationMs: number,
): void => {
options.recordEvidence({
id: `${operation}-${evidenceSequence++}`,
operation,
label,
equivalentCli,
request,
response,
durationMs,
});
};
return {
async run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T> {
let response: RpcResponse;
try {
response = await invoke(operation, options.target, params);
} catch (error) {
record(
operation,
`${operation} failed`,
"unavailable: operation failed before CLI metadata",
null,
null,
0,
);
throw new ConsoleClientError(
"transport",
operation,
errorMessage(error),
);
}
if (!response.ok) {
record(
operation,
`${operation} failed`,
"unavailable: operation failed before CLI metadata",
response.exchange.request,
response.exchange.response,
0,
);
throw new ConsoleClientError(
errorKindForCode(response.error.code),
operation,
response.error.message,
);
}
record(
response.operation,
response.label,
response.equivalentCli,
response.exchange.request,
response.exchange.response,
response.durationMs,
);
try {
return decode(response.interpreted);
} catch (error) {
throw new ConsoleClientError(
"decode",
operation,
errorMessage(error),
);
}
},
};
};