refactor: share console operation execution

This commit is contained in:
lda
2026-08-09 04:51:31 +07:00 Verified
parent cf9b9da1db
commit a35a32eba4
7 changed files with 259 additions and 333 deletions
@@ -5,6 +5,9 @@ import {
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type InputBinding,
type InputPathBinding,
type InputValueBinding,
type SetDraftRouteInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
@@ -27,6 +30,18 @@ const canonicalWorkspace = {
draft: { steps: [] },
};
const pathBinding = {
target: "text",
path: "input.text",
} satisfies InputPathBinding;
const valueBinding = {
target: "mode",
value: { value: "full" },
} satisfies InputValueBinding;
const canonicalBindings = [pathBinding, valueBinding] satisfies ReadonlyArray<InputBinding>;
const createExecutor = () => {
const run = vi.fn();
const executor: ConsoleWriteExecutor = {
@@ -79,7 +94,7 @@ describe("DraftAuthoringClient", () => {
routeFromOutcome: " success ",
routes: { success: "enrich" },
inputMap: { text: "input.text" },
inputBindings: [{ target: "text", path: "input.text" }],
inputBindings: canonicalBindings,
bindOutputs: { result: "state.result" },
description: "Enrich report",
retry: 2,
@@ -91,7 +106,7 @@ describe("DraftAuthoringClient", () => {
stepId: " enrich ",
update: {
description: "Updated",
input: [{ target: "text", value: "updated" }],
input: [valueBinding],
retry: null,
timeoutSeconds: 45,
},
@@ -162,7 +177,7 @@ describe("DraftAuthoringClient", () => {
route_from_outcome: "success",
routes: { success: "enrich" },
input_map: { text: "input.text" },
input_bindings: [{ target: "text", path: "input.text" }],
input_bindings: canonicalBindings,
bind_outputs: { result: "state.result" },
desc: "Enrich report",
retry: 2,
@@ -179,7 +194,7 @@ describe("DraftAuthoringClient", () => {
step_id: "enrich",
update: {
desc: "Updated",
input: [{ target: "text", value: "updated" }],
input: [valueBinding],
retry: null,
timeout_seconds: 45,
},
@@ -5,6 +5,7 @@ import {
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type DraftWorkspace,
type InputBinding,
type SetDraftRouteInput,
type UpdateCapabilityStepInput,
} from "./draft-workspace-models.js";
@@ -50,6 +51,11 @@ const ifDefined = <T>(
if (value !== undefined) target[key] = value;
};
const copyBindings = (
bindings: ReadonlyArray<InputBinding> | null | undefined,
): InputBinding[] | null | undefined =>
bindings === undefined || bindings === null ? bindings : [...bindings];
export const createDraftAuthoringClient = (
executor: ConsoleWriteExecutor,
): DraftAuthoringClient => ({
@@ -114,7 +120,7 @@ export const createDraftAuthoringClient = (
);
ifDefined(params, "routes", input.routes);
ifDefined(params, "input_map", input.inputMap);
ifDefined(params, "input_bindings", input.inputBindings);
ifDefined(params, "input_bindings", copyBindings(input.inputBindings));
ifDefined(params, "bind_outputs", input.bindOutputs);
ifDefined(params, "desc", input.description);
ifDefined(params, "retry", input.retry);
@@ -126,7 +132,7 @@ export const createDraftAuthoringClient = (
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, "input", copyBindings(input.update.input));
ifDefined(update, "retry", input.update.retry);
ifDefined(update, "timeout_seconds", input.update.timeoutSeconds);
return executor.run(
@@ -4,6 +4,7 @@ import {
decodeDraftWorkspacePage,
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
type InputBinding,
} from "./draft-workspace-models.js";
const summary = {
@@ -15,6 +16,25 @@ const summary = {
};
describe("draft workspace models", () => {
it("requires canonical path or value binding shapes", () => {
const canonicalBinding = {
target: "text",
path: "input.text",
} satisfies InputBinding;
const invalidInput: AddCapabilityStepInput = {
workspaceId: "draft-report",
revision: 1,
stepId: "read",
capabilityName: "demo.read",
// @ts-expect-error Binding payloads must use canonical path/value keys.
inputBindings: [{ sourcePath: "input.text", targetPath: "text" }],
};
expect(canonicalBinding).toEqual({ target: "text", path: "input.text" });
expect(invalidInput).toBeDefined();
});
it("exposes camelCase inputs for draft authoring", () => {
const emptyInput = {
workspaceId: "draft-report",
@@ -2,6 +2,32 @@ import * as v from "valibot";
export type JsonObject = Record<string, unknown>;
export type InputPath =
| string
| {
readonly parts: string[];
readonly root: "input" | "state" | "context";
};
export type LocalInputPath =
| string
| {
readonly parts: string[];
readonly root: "local";
};
export type InputPathBinding = {
readonly path: InputPath;
readonly target: LocalInputPath;
};
export type InputValueBinding = {
readonly target: LocalInputPath;
readonly value: JsonObject;
};
export type InputBinding = InputPathBinding | InputValueBinding;
export type CreateEmptyDraftInput = {
readonly workspaceId: string;
readonly name: string;
@@ -36,7 +62,7 @@ export type AddCapabilityStepInput = {
readonly routeFromOutcome?: string;
readonly routes?: Record<string, string> | null;
readonly inputMap?: Record<string, string> | null;
readonly inputBindings?: ReadonlyArray<unknown> | null;
readonly inputBindings?: ReadonlyArray<InputBinding> | null;
readonly bindOutputs?: Record<string, string>;
readonly description?: string | null;
readonly retry?: number | null;
@@ -49,7 +75,7 @@ export type UpdateCapabilityStepInput = {
readonly stepId: string;
readonly update: {
readonly description?: string | null;
readonly input?: ReadonlyArray<unknown> | null;
readonly input?: ReadonlyArray<InputBinding> | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
};
@@ -0,0 +1,166 @@
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 ConsoleExecutor {
run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T>;
}
export type ConsoleExecutorOptions = {
readonly target: string;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly allocateEvidenceId?: (operation: string) => string;
readonly shouldRecordEvidence?: () => boolean;
readonly invoke?: (
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 "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";
/** Keeps read and write executors on one evidence/error protocol. */
export const createConsoleExecutor = (
options: ConsoleExecutorOptions,
): ConsoleExecutor => {
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 },
);
}
},
};
};
@@ -1,164 +1,11 @@
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";
import {
createConsoleExecutor,
type ConsoleExecutor,
type ConsoleExecutorOptions,
} from "./executor-protocol.js";
export interface ConsoleReadExecutor {
run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T>;
}
export interface ConsoleReadExecutor extends ConsoleExecutor {}
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 createConsoleReadExecutor = (options: {
readonly target: string;
readonly recordEvidence: (record: EvidenceRecord) => void;
readonly allocateEvidenceId?: EvidenceIdAllocator;
readonly shouldRecordEvidence?: () => boolean;
readonly invoke?: InvokeOperation;
}): ConsoleReadExecutor => {
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 },
);
}
},
};
};
export const createConsoleReadExecutor = (
options: ConsoleExecutorOptions,
): ConsoleReadExecutor => createConsoleExecutor(options);
@@ -1,165 +1,11 @@
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";
import {
createConsoleExecutor,
type ConsoleExecutor,
type ConsoleExecutorOptions,
} from "./executor-protocol.js";
export interface ConsoleWriteExecutor {
run<T>(
operation: OperationName,
params: unknown,
decode: (value: unknown) => T,
): Promise<T>;
}
export interface ConsoleWriteExecutor extends ConsoleExecutor {}
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 },
);
}
},
};
};
export const createConsoleWriteExecutor = (
options: ConsoleExecutorOptions,
): ConsoleWriteExecutor => createConsoleExecutor(options);