feat: control selected-step dataflow mutations

This commit is contained in:
lda
2026-08-09 23:18:37 +07:00 Verified
parent 15a2b61a17
commit cfd810389e
3 changed files with 422 additions and 10 deletions
@@ -70,6 +70,9 @@ const controller = {
resetGeneration: 0,
addCapability: vi.fn(),
updateCapability: vi.fn(),
setStepInputs: vi.fn(),
setStepOutputs: vi.fn(),
updateSetup: vi.fn(),
setRoute: vi.fn(),
validate: vi.fn(),
reload: vi.fn(),
@@ -1,7 +1,11 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useConsoleWorkspace } from "../context.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type {
DraftWorkspace,
InputBinding,
OutputBinding,
} from "../domain/draft-workspace-models.js";
import type { DraftAuthoringClient } from "../domain/draft-authoring-client.js";
import type { DraftWorkspaceClient } from "../domain/draft-workspace-client.js";
import type { ConsoleReadExecutor } from "../domain/read-executor.js";
@@ -10,6 +14,7 @@ import type { OperationName } from "../../connection/contracts.js";
import type { WorkbenchSelection } from "./authoring-graph.js";
import { createDraftAuthoringClient } from "../domain/draft-authoring-client.js";
import { createDraftWorkspaceClient } from "../domain/draft-workspace-client.js";
import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
import { useDraftAuthoring } from "./useDraftAuthoring.js";
vi.mock("../context.js", () => ({ useConsoleWorkspace: vi.fn() }));
@@ -459,4 +464,221 @@ describe("useDraftAuthoring", () => {
expect(result.current.draft).toBe(initial);
expect(result.current.dirty).toBe(true);
});
it("submits selected-step inputs against the selected node and current revision", async () => {
const initial = workspace({ revision: 7 });
const canonical = workspace({ revision: 8 });
const bindings = [
{ path: "input.title", target: "title" },
{ target: "separator", value: null },
] satisfies ReadonlyArray<InputBinding>;
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 });
const bindings = [
{ source: "text", target: "state.report" },
{ source: "text", target: "state.audit.latest" },
] satisfies ReadonlyArray<OutputBinding>;
setStepOutputBindings.mockResolvedValue(canonical);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepOutputs(bindings));
expect(setStepOutputBindings).toHaveBeenCalledWith({
workspaceId: "draft-report",
revision: 4,
stepId: "render",
bindings,
});
expect(result.current.draft).toBe(canonical);
});
it("sends only present setup fields, including zero and explicit null", async () => {
const initial = workspace({ revision: 7 });
updateCapabilityStep
.mockResolvedValueOnce(workspace({ revision: 8 }))
.mockResolvedValueOnce(workspace({ revision: 9 }));
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
const retryPatch = { retry: 0 } satisfies CapabilitySetupPatch;
await act(async () => result.current.updateSetup(retryPatch));
await act(async () => result.current.updateSetup({ timeoutSeconds: null }));
expect(updateCapabilityStep).toHaveBeenNthCalledWith(1, {
workspaceId: "draft-report",
revision: 7,
stepId: "render",
update: { retry: 0 },
});
expect(updateCapabilityStep).toHaveBeenNthCalledWith(2, {
workspaceId: "draft-report",
revision: 8,
stepId: "render",
update: { timeoutSeconds: null },
});
});
it("coalesces duplicate selected-step mutations and rejects a different pending mutation", async () => {
const initial = workspace({ revision: 6 });
let resolveInputs: ((value: DraftWorkspace) => void) | undefined;
setStepInputBindings.mockReturnValueOnce(
new Promise<DraftWorkspace>((resolve) => { resolveInputs = resolve; }),
);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
const bindings = [{ path: "input.title", target: "title" }] satisfies ReadonlyArray<InputBinding>;
let first: Promise<void> | undefined;
let duplicate: Promise<void> | undefined;
act(() => {
first = result.current.setStepInputs(bindings);
duplicate = result.current.setStepInputs(bindings);
});
const different = result.current.setStepOutputs([
{ source: "text", target: "state.report" },
] satisfies ReadonlyArray<OutputBinding>);
expect(first).toBe(duplicate);
await expect(different).rejects.toThrow("Another draft authoring request is in progress.");
expect(setStepInputBindings).toHaveBeenCalledTimes(1);
expect(setStepOutputBindings).not.toHaveBeenCalled();
resolveInputs?.(workspace({ revision: 7 }));
await act(async () => first);
});
it("ignores a selected-step response after the selection target becomes stale", async () => {
const initial = workspace({ revision: 7 });
let resolveInputs: ((value: DraftWorkspace) => void) | undefined;
setStepInputBindings.mockReturnValueOnce(
new Promise<DraftWorkspace>((resolve) => { resolveInputs = resolve; }),
);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
const request = result.current.setStepInputs([
{ path: "input.title", target: "title" },
]);
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
resolveInputs?.(workspace({ revision: 8 }));
await act(async () => request);
expect(result.current.draft).toBe(initial);
expect(result.current.selection).toEqual({ kind: "node", nodeId: "publish" });
});
it("reapplies the exact input submission to its original step after reload", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
const bindings = [
{ path: "input.items", target: "items" },
{ target: "separator", value: null },
{ path: "state.fallback", target: "fallback" },
] satisfies ReadonlyArray<InputBinding>;
setStepInputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepInputs(bindings));
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(setStepInputBindings).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
bindings,
});
expect(result.current.draft).toBe(canonical);
});
it("reapplies the exact output submission to its original step after reload", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
const bindings = [
{ source: "text", target: "state.report" },
{ source: "text", target: "state.audit.latest" },
] satisfies ReadonlyArray<OutputBinding>;
setStepOutputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepOutputs(bindings));
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(setStepOutputBindings).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
bindings,
});
expect(result.current.draft).toBe(canonical);
});
it("reapplies the exact setup patch to its original step after reload", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
updateCapabilityStep.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
const patch = { retry: 0 } satisfies CapabilitySetupPatch;
await act(async () => result.current.updateSetup(patch));
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(updateCapabilityStep).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
update: { retry: 0 },
});
expect(result.current.draft).toBe(canonical);
});
});
@@ -8,7 +8,11 @@ import {
createDraftWorkspaceClient,
type DraftWorkspaceClient,
} from "../domain/draft-workspace-client.js";
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
import type {
DraftWorkspace,
InputBinding,
OutputBinding,
} from "../domain/draft-workspace-models.js";
import type { CapabilityNodeFormValue } from "./CapabilityNodeForm.js";
import type { RouteFormValue } from "./RouteForm.js";
import {
@@ -16,6 +20,7 @@ import {
type InsertionContext,
type WorkbenchSelection,
} from "./authoring-graph.js";
import type { CapabilitySetupPatch } from "./selected-step-dataflow.js";
export type DraftAuthoringPhase = "idle" | "saving" | "conflict" | "error";
@@ -30,6 +35,9 @@ 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 setStepOutputs: (bindings: ReadonlyArray<OutputBinding>) => Promise<void>;
readonly updateSetup: (patch: CapabilitySetupPatch) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
readonly validate: () => Promise<void>;
readonly reload: () => Promise<void>;
@@ -80,8 +88,30 @@ type LastSubmission =
readonly input: CapabilityNodeFormValue;
}
| { readonly kind: "route"; readonly input: RouteFormValue }
| {
readonly kind: "setup";
readonly targetStepId: string;
readonly patch: CapabilitySetupPatch;
}
| {
readonly kind: "inputs";
readonly targetStepId: string;
readonly bindings: ReadonlyArray<InputBinding>;
}
| {
readonly kind: "outputs";
readonly targetStepId: string;
readonly bindings: ReadonlyArray<OutputBinding>;
}
| null;
type MutationOptions = {
readonly nextSelection?: WorkbenchSelection;
readonly targetStepId?: string;
readonly allowTargetSelectionChange?: boolean;
readonly submission?: Exclude<LastSubmission, null>;
};
export type PreservedCapabilityForm =
| { readonly kind: "add"; readonly input: CapabilityNodeFormValue }
| { readonly kind: "update"; readonly input: CapabilityNodeFormValue }
@@ -151,6 +181,9 @@ export const useDraftAuthoring = ({
resetGeneration: 0,
}));
const pendingRef = useRef<PendingMutation | null>(null);
// The draft is confirmed canonical state. Selected-step forms own active
// tabs and unsaved rows; this ref stores only the exact mutation payload
// needed for an explicit conflict reapply.
const lastSubmissionRef = useRef<LastSubmission>(null);
const adoptsDraftInput =
@@ -243,7 +276,7 @@ export const useDraftAuthoring = ({
kind: string,
input: unknown,
operation: (client: DraftAuthoringClient, requestDraft: DraftWorkspace) => Promise<DraftWorkspace>,
nextSelection?: WorkbenchSelection,
options: MutationOptions = {},
): Promise<void> => {
const requestDraft = currentDraftRef.current;
const key = mutationKey(kind, input, requestDraft.revision);
@@ -270,6 +303,9 @@ export const useDraftAuthoring = ({
});
}
if (options.submission !== undefined) {
lastSubmissionRef.current = options.submission;
}
const requestProvenance = currentProvenanceRef.current;
setState((current) => ({
...current,
@@ -279,7 +315,14 @@ export const useDraftAuthoring = ({
}));
const promise = operation(authoringClient, requestDraft)
.then((response) => {
const committed = commitResponse(response, requestProvenance, nextSelection);
const targetIsCurrent =
options.targetStepId === undefined ||
options.allowTargetSelectionChange === true ||
(currentSelectionRef.current.kind === "node" &&
currentSelectionRef.current.nodeId === options.targetStepId);
const committed =
targetIsCurrent &&
commitResponse(response, requestProvenance, options.nextSelection);
if (!committed && sameProvenance(requestProvenance, currentProvenanceRef.current)) {
throw new Error("The authoring response did not match the requested workspace.");
}
@@ -304,7 +347,6 @@ export const useDraftAuthoring = ({
const addCapability = useCallback(
(input: CapabilityNodeFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "add", input };
const insertion = currentInsertionContextRef.current;
return runMutation(
"add",
@@ -329,7 +371,10 @@ export const useDraftAuthoring = ({
retry: input.retry,
timeoutSeconds: input.timeoutSeconds,
}),
{ kind: "node", nodeId: input.stepId },
{
nextSelection: { kind: "node", nodeId: input.stepId },
submission: { kind: "add", input },
},
);
},
[runMutation],
@@ -339,7 +384,6 @@ export const useDraftAuthoring = ({
(targetStepId: string, input: CapabilityNodeFormValue): Promise<void> => {
// Persist the immutable mutation target with the form. A conflict may
// outlive the current graph selection before the operator reapplies it.
lastSubmissionRef.current = { kind: "update", targetStepId, input };
return runMutation(
"update",
{ targetStepId, input },
@@ -355,6 +399,9 @@ export const useDraftAuthoring = ({
timeoutSeconds: input.timeoutSeconds,
},
}),
{
submission: { kind: "update", targetStepId, input },
},
);
},
[runMutation],
@@ -373,9 +420,135 @@ export const useDraftAuthoring = ({
[submitCapabilityUpdate],
);
const selectedStepId = useCallback((): string | null => {
const selection = currentSelectionRef.current;
return selection.kind === "node" ? selection.nodeId : null;
}, []);
const missingSelectedStep = useCallback((): Promise<void> => {
const error = new Error("Select a capability node before editing its dataflow.");
setState((current) => ({
...current,
dirty: true,
phase: "error",
message: error.message,
}));
return Promise.reject(error);
}, []);
const submitSetup = useCallback(
(
targetStepId: string,
patch: CapabilitySetupPatch,
allowTargetSelectionChange = false,
): Promise<void> => {
const update = {
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
...(patch.timeoutSeconds !== undefined ? { timeoutSeconds: patch.timeoutSeconds } : {}),
};
return runMutation(
"setup",
{ targetStepId, patch },
(client, requestDraft) =>
client.updateCapabilityStep({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: targetStepId,
update,
}),
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "setup", targetStepId, patch },
},
);
},
[runMutation],
);
const submitStepInputs = useCallback(
(
targetStepId: string,
bindings: ReadonlyArray<InputBinding>,
allowTargetSelectionChange = false,
): Promise<void> =>
runMutation(
"inputs",
{ targetStepId, bindings },
(client, requestDraft) =>
client.setStepInputBindings({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: targetStepId,
bindings,
}),
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "inputs", targetStepId, bindings },
},
),
[runMutation],
);
const submitStepOutputs = useCallback(
(
targetStepId: string,
bindings: ReadonlyArray<OutputBinding>,
allowTargetSelectionChange = false,
): Promise<void> =>
runMutation(
"outputs",
{ targetStepId, bindings },
(client, requestDraft) =>
client.setStepOutputBindings({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: targetStepId,
bindings,
}),
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "outputs", targetStepId, bindings },
},
),
[runMutation],
);
const updateSetup = useCallback(
(patch: CapabilitySetupPatch): Promise<void> => {
const targetStepId = selectedStepId();
return targetStepId === null
? missingSelectedStep()
: submitSetup(targetStepId, patch);
},
[missingSelectedStep, selectedStepId, submitSetup],
);
const setStepInputs = useCallback(
(bindings: ReadonlyArray<InputBinding>): Promise<void> => {
const targetStepId = selectedStepId();
return targetStepId === null
? missingSelectedStep()
: submitStepInputs(targetStepId, bindings);
},
[missingSelectedStep, selectedStepId, submitStepInputs],
);
const setStepOutputs = useCallback(
(bindings: ReadonlyArray<OutputBinding>): Promise<void> => {
const targetStepId = selectedStepId();
return targetStepId === null
? missingSelectedStep()
: submitStepOutputs(targetStepId, bindings);
},
[missingSelectedStep, selectedStepId, submitStepOutputs],
);
const setRoute = useCallback(
(input: RouteFormValue): Promise<void> => {
lastSubmissionRef.current = { kind: "route", input };
return runMutation(
"route",
input,
@@ -387,6 +560,7 @@ export const useDraftAuthoring = ({
outcome: input.outcome,
target: input.target,
}),
{ submission: { kind: "route", input } },
);
},
[runMutation],
@@ -470,8 +644,18 @@ export const useDraftAuthoring = ({
if (last === null) return Promise.resolve();
if (last.kind === "add") return addCapability(last.input);
if (last.kind === "update") return submitCapabilityUpdate(last.targetStepId, last.input);
return setRoute(last.input);
}, [addCapability, setRoute, submitCapabilityUpdate]);
if (last.kind === "route") return setRoute(last.input);
if (last.kind === "setup") return submitSetup(last.targetStepId, last.patch, true);
if (last.kind === "inputs") return submitStepInputs(last.targetStepId, last.bindings, true);
return submitStepOutputs(last.targetStepId, last.bindings, true);
}, [
addCapability,
setRoute,
submitCapabilityUpdate,
submitSetup,
submitStepInputs,
submitStepOutputs,
]);
const rememberCapabilityForm = useCallback(
(kind: "add" | "update", input: CapabilityNodeFormValue): void => {
@@ -505,6 +689,9 @@ export const useDraftAuthoring = ({
resetGeneration,
addCapability,
updateCapability,
setStepInputs,
setStepOutputs,
updateSetup,
setRoute,
validate,
reload,