feat: add console draft authoring client

This commit is contained in:
lda
2026-08-09 04:38:09 +07:00 Verified
parent 59069ecd70
commit cf9b9da1db
12 changed files with 931 additions and 1 deletions
@@ -39,6 +39,16 @@ const successfulRead = (): RpcResponse => ({
durationMs: 4,
});
const successfulWrite = (): RpcResponse => ({
ok: true,
operation: "workflow.draft_workspaces.validate",
label: "Validate draft workspace",
interpreted: {},
exchange: { request: {}, response: { status: 200 } },
equivalentCli: "uv run wf draft validate draft-report",
durationMs: 4,
});
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
@@ -58,6 +68,9 @@ const OutletProbe = () => {
<>
<output data-testid="connected-target">{workspace.connectedTarget ?? "none"}</output>
<output data-testid="executor-state">{workspace.readExecutor ? "available" : "unavailable"}</output>
<output data-testid="write-executor-state">
{workspace.writeExecutor ? "available" : "unavailable"}
</output>
<output data-testid="executor-stable">
{workspace.readExecutor === executorIdentity.current ? "yes" : "no"}
</output>
@@ -74,6 +87,19 @@ const OutletProbe = () => {
>
Read capabilities
</button>
<button
type="button"
disabled={workspace.writeExecutor === null}
onClick={() => {
void workspace.writeExecutor?.run(
"workflow.draft_workspaces.validate",
{ workspace_id: "draft-report" },
(value) => value,
);
}}
>
Validate draft
</button>
<Outlet />
</>
);
@@ -106,6 +132,7 @@ describe("ConsoleWorkspace", () => {
renderWorkspace();
expect(screen.getByTestId("executor-state")).toHaveTextContent("unavailable");
expect(screen.getByTestId("write-executor-state")).toHaveTextContent("unavailable");
expect(mockedConnectToServer).not.toHaveBeenCalled();
expect(mockedCallOperation).not.toHaveBeenCalled();
});
@@ -120,6 +147,7 @@ describe("ConsoleWorkspace", () => {
"http://one.example/rpc",
);
expect(screen.getByTestId("executor-state")).toHaveTextContent("available");
expect(screen.getByTestId("write-executor-state")).toHaveTextContent("available");
expect(screen.getByTestId("executor-stable")).toHaveTextContent("yes");
expect(screen.getAllByText("Health check")).toHaveLength(1);
expect(mockedCallOperation).not.toHaveBeenCalled();
@@ -188,6 +216,37 @@ describe("ConsoleWorkspace", () => {
});
});
it("drops a late write receipt from the old connection after reconnect", async () => {
const oldWrite = deferred<RpcResponse>();
mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://one.example/rpc"))
.mockResolvedValueOnce(successfulConnection("http://two.example/rpc"));
mockedCallOperation.mockReturnValueOnce(oldWrite.promise);
renderWorkspace();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Connect" }));
await user.click(await screen.findByRole("button", { name: "Validate draft" }));
expect(mockedCallOperation).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "Reconnect" }));
await waitFor(() => {
expect(screen.getByTestId("connected-target")).toHaveTextContent(
"http://two.example/rpc",
);
expect(screen.getByTestId("evidence-ids")).toHaveTextContent(
"workflow.health-0|workflow.health-1",
);
});
oldWrite.resolve(successfulWrite());
await waitFor(() => {
expect(screen.getByTestId("evidence-ids")).toHaveTextContent(
"workflow.health-0|workflow.health-1",
);
});
});
it("ignores a stale health response after a newer target connects", async () => {
let resolveFirst!: (response: ConnectResponse) => void;
const first = new Promise<ConnectResponse>((resolve) => {
@@ -7,6 +7,7 @@ import {
type EvidenceRecord,
} from "../app/state.js";
import { createConsoleReadExecutor } from "./domain/read-executor.js";
import { createConsoleWriteExecutor } from "./domain/write-executor.js";
import { ConsoleShell } from "./ConsoleShell.js";
import type { ConsoleWorkspaceContextValue } from "./context.js";
@@ -41,6 +42,21 @@ export const ConsoleWorkspace = () => {
[allocateEvidenceId, connectedTarget, recordEvidence],
);
const writeExecutor = useMemo(
() => {
if (!connectedTarget) return null;
const executorGeneration = connectGeneration.current;
return createConsoleWriteExecutor({
target: connectedTarget,
recordEvidence,
allocateEvidenceId,
shouldRecordEvidence: () =>
connectGeneration.current === executorGeneration,
});
},
[allocateEvidenceId, connectedTarget, recordEvidence],
);
const onDraftChange = useCallback(
(value: string) => dispatch({ type: "draft_changed", value }),
[],
@@ -92,8 +108,9 @@ export const ConsoleWorkspace = () => {
connectedTarget,
recordEvidence,
readExecutor,
writeExecutor,
}),
[connectedTarget, readExecutor, recordEvidence, state],
[connectedTarget, readExecutor, recordEvidence, state, writeExecutor],
);
return (
@@ -1,12 +1,14 @@
import { useOutletContext } from "react-router-dom";
import type { ConnectionState, EvidenceRecord } from "../app/state.js";
import type { ConsoleReadExecutor } from "./domain/read-executor.js";
import type { ConsoleWriteExecutor } from "./domain/write-executor.js";
export type ConsoleWorkspaceContextValue = {
readonly connection: ConnectionState;
readonly connectedTarget: string | null;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly readExecutor: ConsoleReadExecutor | null;
readonly writeExecutor: ConsoleWriteExecutor | null;
};
export const useConsoleWorkspace = (): ConsoleWorkspaceContextValue =>
@@ -0,0 +1,246 @@
import { describe, expect, it, vi } from "vitest";
import type { OperationName } from "../../connection/contracts.js";
import {
decodeDraftWorkspace,
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type SetDraftRouteInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
import { createDraftAuthoringClient } from "./draft-authoring-client.js";
import type { ConsoleWriteExecutor } from "./write-executor.js";
const canonicalWorkspace = {
workspaceId: "draft-report",
revision: 3,
title: "Report",
status: "valid" as const,
diagnostics: [],
summary: {
name: "report",
start: "read",
stepCount: 1,
routeCount: 0,
steps: ["read"],
},
draft: { steps: [] },
};
const createExecutor = () => {
const run = vi.fn();
const executor: ConsoleWriteExecutor = {
run: <T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T> => {
run(operation, params, decode);
return Promise.resolve(decode(canonicalWorkspace));
},
};
return { executor, run };
};
describe("DraftAuthoringClient", () => {
it("lowers all six authoring operations and decodes canonical workspaces", async () => {
const { executor: writeExecutor, run } = createExecutor();
const client = createDraftAuthoringClient(writeExecutor);
const createEmptyInput = {
workspaceId: " draft-empty ",
name: " empty ",
title: "Empty",
inputSchema: { type: "object" },
stateSchema: { type: "object" },
outputSchema: { type: "object" },
outcomes: ["done"],
} satisfies CreateEmptyDraftInput;
const createFromCapabilityInput = {
workspaceId: " draft-report ",
capabilityName: " demo.read ",
name: " report ",
title: "Report",
inputSchema: { type: "object" },
stateSchema: null,
outputSchema: { type: "object" },
input: [{ target: "text", value: "hello" }],
output: null,
inputMap: { text: "input.text" },
outputMap: null,
errorMessageSource: "error.message",
} satisfies CreateFromCapabilityInput;
const addCapabilityStepInput = {
workspaceId: " draft-report ",
revision: 3,
stepId: " enrich ",
capabilityName: " demo.enrich ",
routeFromStep: " read ",
routeFromOutcome: " success ",
routes: { success: "enrich" },
inputMap: { text: "input.text" },
inputBindings: [{ target: "text", path: "input.text" }],
bindOutputs: { result: "state.result" },
description: "Enrich report",
retry: 2,
timeoutSeconds: 30,
} satisfies AddCapabilityStepInput;
const updateCapabilityStepInput = {
workspaceId: " draft-report ",
revision: 3,
stepId: " enrich ",
update: {
description: "Updated",
input: [{ target: "text", value: "updated" }],
retry: null,
timeoutSeconds: 45,
},
} satisfies UpdateCapabilityStepInput;
const setRouteInput = {
workspaceId: " draft-report ",
revision: 3,
stepId: " read ",
outcome: " success ",
target: " enrich ",
} satisfies SetDraftRouteInput;
await expect(client.createEmpty(createEmptyInput)).resolves.toEqual(canonicalWorkspace);
await expect(client.createFromCapability(createFromCapabilityInput)).resolves.toEqual(
canonicalWorkspace,
);
await expect(client.addCapabilityStep(addCapabilityStepInput)).resolves.toEqual(
canonicalWorkspace,
);
await expect(client.updateCapabilityStep(updateCapabilityStepInput)).resolves.toEqual(
canonicalWorkspace,
);
await expect(client.setRoute(setRouteInput)).resolves.toEqual(canonicalWorkspace);
await expect(client.validate(" draft-report ")).resolves.toEqual(canonicalWorkspace);
expect(run).toHaveBeenNthCalledWith(
1,
"workflow.draft_workspaces.create_empty",
{
workspace_id: "draft-empty",
name: "empty",
title: "Empty",
input_schema: { type: "object" },
state_schema: { type: "object" },
output_schema: { type: "object" },
outcomes: ["done"],
},
decodeDraftWorkspace,
);
expect(run).toHaveBeenNthCalledWith(
2,
"workflow.draft_workspaces.create_from_capability",
{
workspace_id: "draft-report",
capability_name: "demo.read",
name: "report",
title: "Report",
input_schema: { type: "object" },
state_schema: null,
output_schema: { type: "object" },
input: [{ target: "text", value: "hello" }],
output: null,
input_map: { text: "input.text" },
output_map: null,
error_message_source: "error.message",
},
decodeDraftWorkspace,
);
expect(run).toHaveBeenNthCalledWith(
3,
"workflow.draft_workspaces.add_step_from_capability",
{
workspace_id: "draft-report",
revision: 3,
step_id: "enrich",
capability_name: "demo.enrich",
route_from_step: "read",
route_from_outcome: "success",
routes: { success: "enrich" },
input_map: { text: "input.text" },
input_bindings: [{ target: "text", path: "input.text" }],
bind_outputs: { result: "state.result" },
desc: "Enrich report",
retry: 2,
timeout_seconds: 30,
},
decodeDraftWorkspace,
);
expect(run).toHaveBeenNthCalledWith(
4,
"workflow.draft_workspaces.update_capability_step",
{
workspace_id: "draft-report",
revision: 3,
step_id: "enrich",
update: {
desc: "Updated",
input: [{ target: "text", value: "updated" }],
retry: null,
timeout_seconds: 45,
},
},
decodeDraftWorkspace,
);
expect(run).toHaveBeenNthCalledWith(
5,
"workflow.draft_workspaces.set_route",
{
workspace_id: "draft-report",
revision: 3,
step_id: "read",
outcome: "success",
target: "enrich",
},
decodeDraftWorkspace,
);
expect(run).toHaveBeenNthCalledWith(
6,
"workflow.draft_workspaces.validate",
{ workspace_id: "draft-report" },
decodeDraftWorkspace,
);
});
it("rejects blank identifiers before invoking the executor", async () => {
const { executor: writeExecutor, run } = createExecutor();
const client = createDraftAuthoringClient(writeExecutor);
await expect(client.validate(" \t")).rejects.toMatchObject({
kind: "operation",
operation: "workflow.draft_workspaces.validate",
});
await expect(
client.addCapabilityStep({
workspaceId: "draft-report",
revision: 1,
stepId: " ",
capabilityName: "demo.read",
}),
).rejects.toMatchObject({
kind: "operation",
operation: "workflow.draft_workspaces.add_step_from_capability",
});
expect(run).not.toHaveBeenCalled();
});
it("rejects malformed canonical workspaces through the decoder", async () => {
const writeExecutor: ConsoleWriteExecutor = {
run: <T>(
_operation: OperationName,
_params: unknown,
decode: (value: unknown) => T,
): Promise<T> =>
Promise.resolve(decode({ workspaceId: "draft-report" })),
};
const client = createDraftAuthoringClient(writeExecutor);
await expect(client.validate("draft-report")).rejects.toThrow(
/DraftWorkspace is malformed/,
);
});
});
@@ -0,0 +1,167 @@
import type { OperationName } from "../../connection/contracts.js";
import {
decodeDraftWorkspace,
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type DraftWorkspace,
type SetDraftRouteInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
import { ConsoleClientError } from "./errors.js";
import type { ConsoleWriteExecutor } from "./write-executor.js";
export interface DraftAuthoringClient {
createEmpty(input: CreateEmptyDraftInput): Promise<DraftWorkspace>;
createFromCapability(input: CreateFromCapabilityInput): Promise<DraftWorkspace>;
addCapabilityStep(input: AddCapabilityStepInput): Promise<DraftWorkspace>;
updateCapabilityStep(input: UpdateCapabilityStepInput): Promise<DraftWorkspace>;
setRoute(input: SetDraftRouteInput): Promise<DraftWorkspace>;
validate(workspaceId: string): Promise<DraftWorkspace>;
}
const invalidInput = (operation: OperationName, message: string): ConsoleClientError =>
new ConsoleClientError("operation", operation, message);
const requireIdentifier = (
operation: OperationName,
value: string,
label: string,
): string => {
const normalizedValue = value.trim();
if (!normalizedValue) throw invalidInput(operation, `${label} must not be blank`);
return normalizedValue;
};
const optionalIdentifier = (
operation: OperationName,
value: string | null | undefined,
label: string,
): string | null | undefined =>
value === undefined || value === null
? value
: requireIdentifier(operation, value, label);
const ifDefined = <T>(
target: Record<string, unknown>,
key: string,
value: T | undefined,
): void => {
if (value !== undefined) target[key] = value;
};
export const createDraftAuthoringClient = (
executor: ConsoleWriteExecutor,
): DraftAuthoringClient => ({
createEmpty: async (input) => {
const operation = "workflow.draft_workspaces.create_empty";
const params: Record<string, unknown> = {
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
name: requireIdentifier(operation, input.name, "draft name"),
};
ifDefined(params, "title", input.title);
ifDefined(params, "input_schema", input.inputSchema);
ifDefined(params, "state_schema", input.stateSchema);
ifDefined(params, "output_schema", input.outputSchema);
ifDefined(params, "outcomes", input.outcomes);
return executor.run(operation, params, decodeDraftWorkspace);
},
createFromCapability: async (input) => {
const operation = "workflow.draft_workspaces.create_from_capability";
const params: Record<string, unknown> = {
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
capability_name: requireIdentifier(
operation,
input.capabilityName,
"capability name",
),
};
ifDefined(params, "name", optionalIdentifier(operation, input.name, "draft name"));
ifDefined(params, "title", input.title);
ifDefined(params, "input_schema", input.inputSchema);
ifDefined(params, "state_schema", input.stateSchema);
ifDefined(params, "output_schema", input.outputSchema);
ifDefined(params, "input", input.input);
ifDefined(params, "output", input.output);
ifDefined(params, "input_map", input.inputMap);
ifDefined(params, "output_map", input.outputMap);
ifDefined(params, "error_message_source", input.errorMessageSource);
return executor.run(operation, params, decodeDraftWorkspace);
},
addCapabilityStep: async (input) => {
const operation = "workflow.draft_workspaces.add_step_from_capability";
const params: Record<string, unknown> = {
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
step_id: requireIdentifier(operation, input.stepId, "step id"),
capability_name: requireIdentifier(
operation,
input.capabilityName,
"capability name",
),
};
ifDefined(
params,
"route_from_step",
optionalIdentifier(operation, input.routeFromStep, "route source step"),
);
ifDefined(
params,
"route_from_outcome",
optionalIdentifier(operation, input.routeFromOutcome, "route source outcome"),
);
ifDefined(params, "routes", input.routes);
ifDefined(params, "input_map", input.inputMap);
ifDefined(params, "input_bindings", input.inputBindings);
ifDefined(params, "bind_outputs", input.bindOutputs);
ifDefined(params, "desc", input.description);
ifDefined(params, "retry", input.retry);
ifDefined(params, "timeout_seconds", input.timeoutSeconds);
return executor.run(operation, params, decodeDraftWorkspace);
},
updateCapabilityStep: async (input) => {
const operation = "workflow.draft_workspaces.update_capability_step";
const update: Record<string, unknown> = {};
ifDefined(update, "desc", input.update.description);
ifDefined(update, "input", input.update.input);
ifDefined(update, "retry", input.update.retry);
ifDefined(update, "timeout_seconds", input.update.timeoutSeconds);
return executor.run(
operation,
{
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
step_id: requireIdentifier(operation, input.stepId, "step id"),
update,
},
decodeDraftWorkspace,
);
},
setRoute: async (input) => {
const operation = "workflow.draft_workspaces.set_route";
return executor.run(
operation,
{
workspace_id: requireIdentifier(operation, input.workspaceId, "workspace id"),
revision: input.revision,
step_id: requireIdentifier(operation, input.stepId, "step id"),
outcome: requireIdentifier(operation, input.outcome, "outcome"),
target: requireIdentifier(operation, input.target, "route target"),
},
decodeDraftWorkspace,
);
},
validate: async (workspaceId) => {
const operation = "workflow.draft_workspaces.validate";
return executor.run(
operation,
{ workspace_id: requireIdentifier(operation, workspaceId, "workspace id") },
decodeDraftWorkspace,
);
},
});
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
decodeDraftWorkspace,
decodeDraftWorkspacePage,
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
} from "./draft-workspace-models.js";
const summary = {
@@ -13,6 +15,22 @@ const summary = {
};
describe("draft workspace models", () => {
it("exposes camelCase inputs for draft authoring", () => {
const emptyInput = {
workspaceId: "draft-report",
name: "report",
} satisfies CreateEmptyDraftInput;
const stepInput = {
workspaceId: "draft-report",
revision: 1,
stepId: "read",
capabilityName: "demo.read",
} satisfies AddCapabilityStepInput;
expect(emptyInput.workspaceId).toBe("draft-report");
expect(stepInput.capabilityName).toBe("demo.read");
});
it("preserves opaque summary values and defaults an omitted draft", () => {
const workspace = decodeDraftWorkspace({
workspaceId: "draft-report",
@@ -1,5 +1,68 @@
import * as v from "valibot";
export type JsonObject = Record<string, unknown>;
export type CreateEmptyDraftInput = {
readonly workspaceId: string;
readonly name: string;
readonly title?: string | null;
readonly inputSchema?: JsonObject | null;
readonly stateSchema?: JsonObject | null;
readonly outputSchema?: JsonObject | null;
readonly outcomes?: ReadonlyArray<string>;
};
export type CreateFromCapabilityInput = {
readonly workspaceId: string;
readonly capabilityName: string;
readonly name?: string | null;
readonly title?: string | null;
readonly inputSchema?: JsonObject | null;
readonly stateSchema?: JsonObject | null;
readonly outputSchema?: JsonObject | null;
readonly input?: ReadonlyArray<unknown> | null;
readonly output?: ReadonlyArray<unknown> | null;
readonly inputMap?: Record<string, string> | null;
readonly outputMap?: Record<string, string> | null;
readonly errorMessageSource?: unknown;
};
export type AddCapabilityStepInput = {
readonly workspaceId: string;
readonly revision: number;
readonly stepId: string;
readonly capabilityName: string;
readonly routeFromStep?: string | null;
readonly routeFromOutcome?: string;
readonly routes?: Record<string, string> | null;
readonly inputMap?: Record<string, string> | null;
readonly inputBindings?: ReadonlyArray<unknown> | null;
readonly bindOutputs?: Record<string, string>;
readonly description?: string | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
};
export type UpdateCapabilityStepInput = {
readonly workspaceId: string;
readonly revision: number;
readonly stepId: string;
readonly update: {
readonly description?: string | null;
readonly input?: ReadonlyArray<unknown> | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
};
};
export type SetDraftRouteInput = {
readonly workspaceId: string;
readonly revision: number;
readonly stepId: string;
readonly outcome: string;
readonly target: string;
};
const decode = <T>(
label: string,
schema: v.GenericSchema<unknown, T>,
@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";
import { ConsoleApiError } from "../../connection/api.js";
import type { OperationName, RpcResponse } from "../../connection/contracts.js";
import { createConsoleWriteExecutor } from "./write-executor.js";
const createSuccess = (
interpreted: unknown,
operation: OperationName = "workflow.draft_workspaces.create_empty",
): RpcResponse => ({
ok: true,
operation,
label: "Create empty draft workspace",
interpreted,
exchange: { request: { sent: true }, response: { status: 200 } },
equivalentCli: "uv run wf draft create draft-report --name report",
durationMs: 4,
});
const createFailure = (code: string): RpcResponse => ({
ok: false,
error: { code, message: "operation failed" },
exchange: { request: { sent: true }, response: { status: 502 } },
});
describe("ConsoleWriteExecutor", () => {
it("records one receipt and returns decoded mutation data", async () => {
const invoke = vi.fn(async () => createSuccess({ revision: 2 }));
const recordEvidence = vi.fn();
const executor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke,
});
const result = await executor.run(
"workflow.draft_workspaces.create_empty",
{ workspace_id: "draft-report" },
(value) => ({ decoded: value }),
);
expect(result).toEqual({ decoded: { revision: 2 } });
expect(invoke).toHaveBeenCalledWith(
"workflow.draft_workspaces.create_empty",
"http://console.test/rpc",
{ workspace_id: "draft-report" },
);
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
id: "workflow.draft_workspaces.create_empty-0",
operation: "workflow.draft_workspaces.create_empty",
request: { sent: true },
response: { status: 200 },
durationMs: 4,
});
});
it("records server failures and maps their error kind", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => createFailure("upstream_unreachable")),
});
await expect(
executor.run("workflow.draft_workspaces.validate", {}, (value) => value),
).rejects.toMatchObject({
kind: "connection",
operation: "workflow.draft_workspaces.validate",
});
expect(recordEvidence).toHaveBeenCalledTimes(1);
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
operation: "workflow.draft_workspaces.validate",
label: "workflow.draft_workspaces.validate failed",
response: { status: 502 },
});
});
it("rejects an operation mismatch before decoding", async () => {
const recordEvidence = vi.fn();
const decode = vi.fn((value: unknown) => value);
const executor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () =>
createSuccess(
null,
"workflow.draft_workspaces.set_route",
),
),
});
await expect(
executor.run("workflow.draft_workspaces.create_empty", {}, decode),
).rejects.toMatchObject({
kind: "operation",
message:
"operation mismatch: requested workflow.draft_workspaces.create_empty, received workflow.draft_workspaces.set_route",
});
expect(decode).not.toHaveBeenCalled();
expect(recordEvidence.mock.calls[0]?.[0]).toMatchObject({
operation: "workflow.draft_workspaces.create_empty",
equivalentCli: "unavailable: response operation mismatch",
});
});
it("preserves decoder and transport causes", async () => {
const decodeCause = new Error("invalid draft response");
const decodeExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
invoke: vi.fn(async () => createSuccess({ malformed: true })),
});
await expect(
decodeExecutor.run("workflow.draft_workspaces.create_empty", {}, () => {
throw decodeCause;
}),
).rejects.toMatchObject({ kind: "decode", cause: decodeCause });
const transportCause = new Error("fetch failed");
const transportExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
invoke: vi.fn(async () => {
throw transportCause;
}),
});
await expect(
transportExecutor.run("workflow.draft_workspaces.create_empty", {}, (value) => value),
).rejects.toMatchObject({ kind: "transport", cause: transportCause });
const protocolExecutor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence: vi.fn(),
invoke: vi.fn(async () => {
throw new ConsoleApiError("protocol", "malformed JSON response");
}),
});
await expect(
protocolExecutor.run("workflow.draft_workspaces.create_empty", {}, (value) => value),
).rejects.toMatchObject({ kind: "decode" });
});
it("records elapsed duration for failures without response metadata", async () => {
const now = vi
.spyOn(performance, "now")
.mockReturnValueOnce(100)
.mockReturnValueOnce(137);
const recordEvidence = vi.fn();
const executor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence,
invoke: vi.fn(async () => createFailure("upstream_unreachable")),
});
await expect(
executor.run("workflow.draft_workspaces.validate", {}, (value) => value),
).rejects.toMatchObject({ kind: "connection" });
expect(recordEvidence.mock.calls[0]?.[0]?.durationMs).toBe(37);
now.mockRestore();
});
it("suppresses evidence for a stale connection generation", async () => {
const recordEvidence = vi.fn();
const executor = createConsoleWriteExecutor({
target: "http://console.test/rpc",
recordEvidence,
shouldRecordEvidence: () => false,
invoke: vi.fn(async () => createSuccess(null)),
});
await executor.run("workflow.draft_workspaces.create_empty", {}, (value) => value);
expect(recordEvidence).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,165 @@
import { callOperation, ConsoleApiError } 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 ConsoleWriteExecutor {
run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T>;
}
type InvokeOperation = (
operation: OperationName,
target: string,
params: unknown,
) => Promise<RpcResponse>;
type EvidenceIdAllocator = (operation: string) => string;
const errorKindForCode = (code: string): ConsoleClientErrorKind => {
switch (code) {
case "invalid_target":
case "upstream_unreachable":
return "connection";
case "unknown_operation":
return "operation";
case "rpc_decode_error":
return "decode";
default:
return "operation";
}
};
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
const clientErrorKindForInvocation = (
error: unknown,
): "decode" | "transport" =>
error instanceof ConsoleApiError && error.kind !== "transport"
? "decode"
: "transport";
export const createConsoleWriteExecutor = (options: {
readonly target: string;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly allocateEvidenceId?: EvidenceIdAllocator;
readonly shouldRecordEvidence?: () => boolean;
readonly invoke?: InvokeOperation;
}): ConsoleWriteExecutor => {
let evidenceSequence = 0;
const invoke = options.invoke ?? callOperation;
const allocateEvidenceId =
options.allocateEvidenceId ??
((operation: string): string => `${operation}-${evidenceSequence++}`);
const record = (
operation: OperationName,
label: string,
equivalentCli: string,
request: unknown,
response: unknown,
durationMs: number,
): void => {
if (options.shouldRecordEvidence?.() === false) return;
options.recordEvidence({
id: allocateEvidenceId(operation),
operation,
label,
equivalentCli,
request,
response,
durationMs,
});
};
return {
async run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T> {
const startedAt = performance.now();
const durationSinceStart = (): number =>
Math.max(0, Math.round(performance.now() - startedAt));
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,
durationSinceStart(),
);
throw new ConsoleClientError(
clientErrorKindForInvocation(error),
operation,
errorMessage(error),
{ cause: error },
);
}
if (!response.ok) {
record(
operation,
`${operation} failed`,
"unavailable: operation failed before CLI metadata",
response.exchange.request,
response.exchange.response,
durationSinceStart(),
);
throw new ConsoleClientError(
errorKindForCode(response.error.code),
operation,
response.error.message,
);
}
if (response.operation !== operation) {
record(
operation,
`${operation} failed`,
"unavailable: response operation mismatch",
response.exchange.request,
response.exchange.response,
response.durationMs,
);
throw new ConsoleClientError(
"operation",
operation,
`operation mismatch: requested ${operation}, received ${response.operation}`,
);
}
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),
{ cause: error },
);
}
},
};
};
@@ -91,6 +91,7 @@ beforeEach(() => {
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor,
writeExecutor: null,
});
});
@@ -158,6 +159,7 @@ describe("useCapabilityDiscovery", () => {
connectedTarget: "http://new-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
writeExecutor: null,
});
const renderCountBeforeTargetChange = renders.length;
rerender();
@@ -184,6 +186,7 @@ describe("useCapabilityDiscovery", () => {
connectedTarget: "http://reconnected-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
writeExecutor: null,
});
rerender();
@@ -272,6 +275,7 @@ describe("useCapabilityDiscovery", () => {
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
const { result } = renderHook(() => useCapabilityDiscovery());
@@ -84,6 +84,7 @@ beforeEach(() => {
connectedTarget: connectedState.connectedTarget,
recordEvidence: vi.fn(),
readExecutor,
writeExecutor: null,
});
});
@@ -152,6 +153,7 @@ describe("useDraftWorkspace", () => {
connectedTarget: "http://new-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
writeExecutor: null,
});
const renderCountBeforeTargetChange = renders.length;
rerender({ workspaceId: "draft-first" });
@@ -202,6 +204,7 @@ describe("useDraftWorkspace", () => {
connectedTarget: "http://new-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
writeExecutor: null,
});
rerender({ workspaceId: "draft-new" });
@@ -228,6 +231,7 @@ describe("useDraftWorkspace", () => {
connectedTarget: "http://new-workflow.example/rpc",
recordEvidence: vi.fn(),
readExecutor: {} as ConsoleReadExecutor,
writeExecutor: null,
});
rerender();
@@ -259,6 +263,7 @@ describe("useDraftWorkspace", () => {
connectedTarget: null,
recordEvidence: vi.fn(),
readExecutor: null,
writeExecutor: null,
});
const { result } = renderHook(() => useDraftWorkspace("draft-report"));