feat: add console draft authoring client
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user