feat: decode composite step inputs

This commit is contained in:
lda
2026-08-13 19:49:39 +07:00 Verified
parent 938864b4d2
commit 1bb684bfcd
22 changed files with 1339 additions and 119 deletions
@@ -1,11 +1,33 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { CapabilityNodeForm, type CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
afterEach(() => cleanup());
describe("CapabilityNodeForm", () => {
it("accepts expression bindings in its callback value without authoring them", () => {
const value = {
stepId: "concat",
capabilityName: "wf.std.concat",
inputBindings: [
{
target: "request",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
},
],
} satisfies CapabilityNodeFormValue;
expect(value.inputBindings).toHaveLength(1);
expect(value.inputBindings[0]).toHaveProperty("expression.kind", "array");
});
it("submits explicit node metadata and serialized schema bindings", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type { InputBinding } from "../domain/draft-workspace-models.js";
import type { StepInputBinding } from "../domain/draft-workspace-models.js";
import { SchemaForm } from "../schema-form/SchemaForm.js";
import { normalizeSchema } from "../schema-form/schema-field.js";
import {
@@ -15,7 +15,7 @@ export type CapabilityNodeFormValue = {
readonly description?: string | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
readonly inputBindings: ReadonlyArray<InputBinding> | null;
readonly inputBindings: ReadonlyArray<StepInputBinding> | null;
readonly inputMap?: Record<string, string> | null;
readonly routes?: Record<string, string> | null;
readonly bindOutputs?: Record<string, string>;
@@ -55,4 +55,25 @@ describe("canonical capability form projection", () => {
expect(projected?.initialInputValue).toEqual({ items: [] });
});
it("preserves a valid expression binding while projecting the legacy form", () => {
const expression = {
kind: "object",
fields: {
value: { kind: "path", path: "state.foo" },
label: { kind: "literal", value: "wowcool" },
},
} as const;
const projected = canonicalCapabilityFormData(
draft({
use: "wf.std.concat",
input: [{ target: "request", expression }],
}),
"render",
);
expect(projected?.initialValue.inputBindings).toEqual([
{ target: "request", expression },
]);
});
});
@@ -1,7 +1,12 @@
import type {
CapabilityNodeFormValue,
} from "./CapabilityNodeForm.js";
import type { InputBinding, InputPath, LocalInputPath } from "../domain/draft-workspace-models.js";
import type {
InputExpression,
InputPath,
LocalInputPath,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type { FieldSources } from "../schema-form/schema-values.js";
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
@@ -84,16 +89,48 @@ const inputPath = (value: unknown): value is InputPath =>
value.parts.every((part): part is string => typeof part === "string")
);
const inputBinding = (value: JsonRecord): InputBinding | null => {
const jsonValue = (value: unknown): boolean => {
if (
value === null ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
typeof value === "string"
) {
return true;
}
if (Array.isArray(value)) return value.every(jsonValue);
return isRecord(value) && Object.values(value).every(jsonValue);
};
const inputExpression = (value: unknown): value is InputExpression => {
if (!isRecord(value) || typeof value.kind !== "string") return false;
switch (value.kind) {
case "literal":
return jsonValue(value.value);
case "path":
return inputPath(value.path);
case "array":
return Array.isArray(value.items) && value.items.every(inputExpression);
case "object":
return isRecord(value.fields) && Object.values(value.fields).every(inputExpression);
default:
return false;
}
};
const inputBinding = (value: JsonRecord): StepInputBinding | null => {
if (!localInputPath(value.target)) return null;
if (inputPath(value.path)) return { target: value.target, path: value.path };
if ("value" in value) return { target: value.target, value: value.value };
if (inputExpression(value.expression)) {
return { target: value.target, expression: value.expression };
}
return null;
};
const bindingFormData = (
capabilityName: string,
inputBindings: ReadonlyArray<InputBinding>,
inputBindings: ReadonlyArray<StepInputBinding>,
): Omit<CanonicalCapabilityFormData, "initialValue"> => {
const initialInputSources: Record<string, FieldSources[string]> = {};
let initialInputValue: unknown = undefined;
@@ -125,10 +162,14 @@ const formDataFromValue = (
const formDataFromBindings = (
capabilityName: string,
inputBindings: ReadonlyArray<InputBinding>,
inputBindings: ReadonlyArray<StepInputBinding>,
initialValue: Partial<CapabilityNodeFormValue>,
): CanonicalCapabilityFormData => {
return { ...bindingFormData(capabilityName, inputBindings), initialValue };
const preservesExpressions = inputBindings.some((binding) => "expression" in binding);
return {
...bindingFormData(capabilityName, inputBindings),
initialValue: preservesExpressions ? { ...initialValue, inputBindings } : initialValue,
};
};
export const capabilityFormDataFromValue = (
@@ -156,7 +197,7 @@ export const canonicalCapabilityFormData = (
? { timeoutSeconds }
: {}),
} satisfies Partial<CapabilityNodeFormValue>;
const inputBindings: InputBinding[] = [];
const inputBindings: StepInputBinding[] = [];
const input = step.input;
if (Array.isArray(input)) {
for (const rawBinding of input) {
@@ -5,6 +5,7 @@ import type {
DraftWorkspace,
InputBinding,
OutputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { DraftAuthoringClient } from "../domain/draft-authoring-client.js";
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
@@ -18,9 +19,10 @@ import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
import { useDraftAuthoring } from "./useDraftAuthoring.js";
vi.mock("../context.js", () => ({ useConsoleWorkspace: vi.fn() }));
vi.mock("../domain/draft-authoring-client.js", () => ({
createDraftAuthoringClient: vi.fn(),
}));
vi.mock("../domain/draft-authoring-client.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../domain/draft-authoring-client.js")>();
return { ...actual, createDraftAuthoringClient: vi.fn() };
});
vi.mock("../domain/draft-workspace-client.js", () => ({
createDraftWorkspaceClient: vi.fn(),
}));
@@ -613,6 +615,37 @@ describe("useDraftAuthoring", () => {
expect(result.current.draft).toBe(canonical);
});
it("submits a recursive expression through the selected-step controller", async () => {
const initial = workspace({ revision: 7 });
const canonical = workspace({ revision: 8 });
const bindings = [
{
target: "request",
expression: {
kind: "object",
fields: {
value: { kind: "path", path: "state.foo" },
},
},
},
] satisfies ReadonlyArray<StepInputBinding>;
setStepInputBindings.mockResolvedValue(canonical);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepInputs(bindings));
expect(setStepInputBindings).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 7,
stepId: "render",
bindings,
});
expect(result.current.draft).toBe(canonical);
});
it("submits ordered output bindings and commits the returned draft", async () => {
const initial = workspace({ revision: 4 });
const canonical = workspace({ revision: 5 });
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useConsoleWorkspace } from "../context.js";
import {
createDraftAuthoringClient,
copyStepInputBinding,
type DraftAuthoringClient,
} from "../domain/draft-authoring-client.js";
import {
@@ -10,8 +11,8 @@ import {
} from "../domain/draft-workspace-client.js";
import type {
DraftWorkspace,
InputBinding,
OutputBinding,
StepInputBinding,
} from "../domain/draft-workspace-models.js";
import type { CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
import type { RouteFormValue } from "./RouteForm.js";
@@ -35,7 +36,7 @@ export interface DraftAuthoringController {
readonly preservedCapabilityForm: PreservedCapabilityForm;
readonly addCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly setStepInputs: (bindings: ReadonlyArray<InputBinding>) => Promise<void>;
readonly setStepInputs: (bindings: ReadonlyArray<StepInputBinding>) => Promise<void>;
readonly setStepOutputs: (bindings: ReadonlyArray<OutputBinding>) => Promise<void>;
readonly updateSetup: (patch: CapabilitySetupPatch) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
@@ -100,7 +101,7 @@ type LastSubmission =
| {
readonly kind: "inputs";
readonly targetStepId: string;
readonly bindings: ReadonlyArray<InputBinding>;
readonly bindings: ReadonlyArray<StepInputBinding>;
}
| {
readonly kind: "outputs";
@@ -160,38 +161,6 @@ const mutationKey = (kind: string, input: unknown, revision: number): string =>
return `${kind}:${revision}:${encoded ?? "undefined"}`;
};
const copyJsonValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(copyJsonValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, copyJsonValue(nestedValue)]),
);
}
return value;
};
const copyInputBinding = (binding: InputBinding): InputBinding => {
if ("path" in binding) {
return {
path:
typeof binding.path === "string"
? binding.path
: { root: binding.path.root, parts: [...binding.path.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
};
}
return {
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
value: copyJsonValue(binding.value),
};
};
const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
source:
typeof binding.source === "string"
@@ -204,8 +173,8 @@ const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
});
const copyInputBindings = (
bindings: ReadonlyArray<InputBinding>,
): ReadonlyArray<InputBinding> => bindings.map(copyInputBinding);
bindings: ReadonlyArray<StepInputBinding>,
): ReadonlyArray<StepInputBinding> => bindings.map(copyStepInputBinding);
const copyOutputBindings = (
bindings: ReadonlyArray<OutputBinding>,
@@ -567,7 +536,7 @@ export const useDraftAuthoring = ({
const submitStepInputs = useCallback(
(
targetStepId: string,
bindings: ReadonlyArray<InputBinding>,
bindings: ReadonlyArray<StepInputBinding>,
allowTargetSelectionChange = false,
): Promise<void> => {
const submittedBindings = copyInputBindings(bindings);
@@ -629,7 +598,7 @@ export const useDraftAuthoring = ({
);
const setStepInputs = useCallback(
(bindings: ReadonlyArray<InputBinding>): Promise<void> => {
(bindings: ReadonlyArray<StepInputBinding>): Promise<void> => {
const targetStepId = selectedStepId();
return targetStepId === null
? missingSelectedStep()
@@ -6,6 +6,7 @@ import {
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type InputBinding,
type StepInputBinding,
type InputPathBinding,
type InputValueBinding,
type OutputBinding,
@@ -476,4 +477,59 @@ describe("DraftAuthoringClient", () => {
decodeDraftWorkspace,
);
});
it("preserves recursive expression bindings when sending node-local inputs", async () => {
const { executor: writeExecutor, run } = createExecutor();
const client = createDraftAuthoringClient(writeExecutor);
const bindings = [
{
target: "request",
expression: {
kind: "object",
fields: {
items: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
separator: { kind: "literal", value: " " },
},
},
},
] satisfies ReadonlyArray<StepInputBinding>;
await client.setStepInputBindings({
workspaceId: "report",
revision: 7,
stepId: "concat",
bindings,
});
expect(run).toHaveBeenCalledWith(
"workflow.draft_workspaces.set_step_input_bindings",
{
workspace_id: "report",
revision: 7,
step_id: "concat",
bindings,
},
decodeDraftWorkspace,
);
const sentParams = run.mock.calls[0]?.[1] as {
readonly bindings: ReadonlyArray<StepInputBinding>;
};
const sourceBinding = bindings[0];
const sentBinding = sentParams.bindings[0];
if (sourceBinding === undefined || sentBinding === undefined) {
throw new Error("expected the recursive binding to be sent");
}
expect(sentParams.bindings).not.toBe(bindings);
expect(sentBinding).not.toBe(sourceBinding);
if (!("expression" in sourceBinding) || !("expression" in sentBinding)) {
throw new Error("expected an expression binding");
}
expect(sentBinding.expression).not.toBe(sourceBinding.expression);
});
});
@@ -5,8 +5,12 @@ import {
type CreateEmptyDraftInput,
type CreateFromCapabilityInput,
type DraftWorkspace,
type InputBinding,
type JsonValue,
type InputExpression,
type InputPath,
type LocalInputPath,
type OutputBinding,
type StepInputBinding,
type SetDraftRouteInput,
type SetStepInputBindingsInput,
type SetStepOutputBindingsInput,
@@ -56,7 +60,9 @@ const ifDefined = <T>(
if (value !== undefined) target[key] = value;
};
const copyJsonValue = (value: unknown): unknown => {
function copyJsonValue(value: JsonValue): JsonValue;
function copyJsonValue(value: unknown): unknown;
function copyJsonValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(copyJsonValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
@@ -67,27 +73,57 @@ const copyJsonValue = (value: unknown): unknown => {
);
}
return value;
}
const copyInputPath = (path: InputPath): InputPath =>
typeof path === "string" ? path : { root: path.root, parts: [...path.parts] };
const copyLocalInputPath = (path: LocalInputPath): LocalInputPath =>
typeof path === "string" ? path : { root: path.root, parts: [...path.parts] };
// Keep this copy explicit so adding a canonical expression variant is a type error
// instead of silently dropping data during a mutation or reapply.
export const copyInputExpression = (
expression: InputExpression,
): InputExpression => {
switch (expression.kind) {
case "literal":
return { kind: "literal", value: copyJsonValue(expression.value) };
case "path":
return { kind: "path", path: copyInputPath(expression.path) };
case "array":
return { kind: "array", items: expression.items.map(copyInputExpression) };
case "object":
return {
kind: "object",
fields: Object.fromEntries(
Object.entries(expression.fields).map(([key, value]) => [
key,
copyInputExpression(value),
]),
),
};
}
};
const copyInputBinding = (binding: InputBinding): InputBinding => {
export const copyStepInputBinding = (
binding: StepInputBinding,
): StepInputBinding => {
if ("path" in binding) {
return {
path:
typeof binding.path === "string"
? binding.path
: { root: binding.path.root, parts: [...binding.path.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
path: copyInputPath(binding.path),
target: copyLocalInputPath(binding.target),
};
}
if ("value" in binding) {
return {
target: copyLocalInputPath(binding.target),
value: copyJsonValue(binding.value),
};
}
return {
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
value: copyJsonValue(binding.value),
target: copyLocalInputPath(binding.target),
expression: copyInputExpression(binding.expression),
};
};
@@ -103,11 +139,11 @@ const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
});
const copyInputBindings = (
bindings: ReadonlyArray<InputBinding> | null | undefined,
): InputBinding[] | null | undefined =>
bindings: ReadonlyArray<StepInputBinding> | null | undefined,
): StepInputBinding[] | null | undefined =>
bindings === undefined || bindings === null
? bindings
: bindings.map(copyInputBinding);
: bindings.map(copyStepInputBinding);
const copyOutputBindings = (
bindings: ReadonlyArray<OutputBinding> | null | undefined,
@@ -5,6 +5,8 @@ import {
type AddCapabilityStepInput,
type CreateEmptyDraftInput,
type InputBinding,
type InputExpression,
type StepInputBinding,
} from "./draft-workspace-models.js";
const summary = {
@@ -35,6 +37,28 @@ describe("draft workspace models", () => {
expect(invalidInput).toBeDefined();
});
it("models a recursive node-local expression without widening workflow outputs", () => {
const expression = {
kind: "object",
fields: {
items: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
},
} satisfies InputExpression;
const binding = {
target: "request",
expression,
} satisfies StepInputBinding;
expect(binding.expression.kind).toBe("object");
expect(binding.expression.fields.items.kind).toBe("array");
});
it("exposes camelCase inputs for draft authoring", () => {
const emptyInput = {
workspaceId: "draft-report",
@@ -2,6 +2,14 @@ import * as v from "valibot";
export type JsonObject = Record<string, unknown>;
export type JsonValue =
| null
| boolean
| number
| string
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue };
export type InputPath =
| string
| {
@@ -28,6 +36,39 @@ export type InputValueBinding = {
export type InputBinding = InputPathBinding | InputValueBinding;
export type LiteralExpression = {
readonly kind: "literal";
readonly value: JsonValue;
};
export type PathExpression = {
readonly kind: "path";
readonly path: InputPath;
};
export type ArrayExpression = {
readonly kind: "array";
readonly items: ReadonlyArray<InputExpression>;
};
export type ObjectExpression = {
readonly kind: "object";
readonly fields: Readonly<Record<string, InputExpression>>;
};
export type InputExpression =
| LiteralExpression
| PathExpression
| ArrayExpression
| ObjectExpression;
export type InputExpressionBinding = {
readonly target: LocalInputPath;
readonly expression: InputExpression;
};
export type StepInputBinding = InputBinding | InputExpressionBinding;
export type StatePath =
| string
| { readonly root: "state"; readonly parts: string[] };
@@ -41,7 +82,7 @@ export type SetStepInputBindingsInput = {
readonly workspaceId: string;
readonly revision: number;
readonly stepId: string;
readonly bindings: ReadonlyArray<InputBinding>;
readonly bindings: ReadonlyArray<StepInputBinding>;
};
export type SetStepOutputBindingsInput = {
@@ -85,7 +126,7 @@ export type AddCapabilityStepInput = {
readonly routeFromOutcome?: string;
readonly routes?: Record<string, string> | null;
readonly inputMap?: Record<string, string> | null;
readonly inputBindings?: ReadonlyArray<InputBinding> | null;
readonly inputBindings?: ReadonlyArray<StepInputBinding> | null;
readonly bindOutputs?: Record<string, string>;
readonly description?: string | null;
readonly retry?: number | null;
@@ -98,7 +139,7 @@ export type UpdateCapabilityStepInput = {
readonly stepId: string;
readonly update: {
readonly description?: string | null;
readonly input?: ReadonlyArray<InputBinding> | null;
readonly input?: ReadonlyArray<StepInputBinding> | null;
readonly retry?: number | null;
readonly timeoutSeconds?: number | null;
};