feat: add console read clients
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user