fix: return canonical draft mutations

This commit is contained in:
lda
2026-08-09 08:19:30 +07:00 Verified
parent a485ca8a6b
commit ef773dedb3
10 changed files with 289 additions and 42 deletions
@@ -73,6 +73,7 @@ const controller = {
markDirty: vi.fn(),
rememberCapabilityForm: vi.fn(),
rememberRouteForm: vi.fn(),
preservedCapabilityForm: null,
} satisfies DraftAuthoringController;
describe("ContextInspector", () => {
@@ -117,6 +118,14 @@ describe("ContextInspector", () => {
repairHint: null,
details: {},
},
{
code: "invalid_node_input_field",
path: "nodes[0].input[0].target",
message: "Destination field is not declared.",
stepId: "read",
repairHint: null,
details: {},
},
],
};
@@ -134,5 +143,54 @@ describe("ContextInspector", () => {
expect(screen.getAllByText("Title is not accepted.")).not.toHaveLength(0);
expect(screen.getAllByText("Retry must be non-negative.")).not.toHaveLength(0);
expect(screen.getAllByText("Destination field is not declared.")).not.toHaveLength(0);
expect(screen.getByRole("textbox", { name: "Title" })).toHaveAttribute(
"aria-invalid",
"true",
);
});
it("keeps the editable update form when a conflict has no canonical draft", () => {
const conflictDraft: DraftWorkspace = {
...draft,
status: "conflict",
diagnostics: [],
summary: { ...draft.summary, steps: [] },
draft: null,
};
const preservedController = {
...controller,
draft: conflictDraft,
preservedCapabilityForm: {
kind: "update" as const,
input: {
stepId: "read",
capabilityName: "demo.read",
description: "Locally edited description",
retry: 3,
timeoutSeconds: 60,
inputBindings: [{ target: "title", value: "Locally edited title" }],
},
},
} satisfies DraftAuthoringController;
render(
<ContextInspector
capabilities={[]}
capabilityDetail={detail}
capabilityDetailMessage={null}
capabilityDetailPhase="ready"
controller={preservedController}
draft={conflictDraft}
selection={{ kind: "node", nodeId: "read" }}
/>,
);
expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue(
"Locally edited description",
);
expect(screen.getByRole("textbox", { name: "Title" })).toHaveValue(
"Locally edited title",
);
});
});
@@ -8,7 +8,11 @@ import { formatBoundedJson } from "./format-bounded-json.js";
import { CapabilityNodeForm } from "./CapabilityNodeForm.js";
import { RouteForm } from "./RouteForm.js";
import type { DraftAuthoringController } from "./useDraftAuthoring.js";
import { canonicalCapabilityFormData } from "./canonical-capability-form.js";
import {
canonicalCapabilityFormData,
capabilityFormDataFromValue,
} from "./canonical-capability-form.js";
import { parseTOMLPath } from "../schema-form/schema-paths.js";
type ContextInspectorProps = {
readonly draft: DraftWorkspace;
@@ -36,27 +40,91 @@ const diagnosticParts = (path: string): string[] => {
return normalized.split(".").filter((part) => part.length > 0);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const schemaPathParts = (value: unknown): ReadonlyArray<string | number> | null => {
if (typeof value === "string") {
const parts = parseTOMLPath(value);
return parts?.map((part) => /^\d+$/.test(part) ? Number(part) : part) ?? null;
}
if (!isRecord(value) || value.root !== "local" || !Array.isArray(value.parts)) return null;
return value.parts.every((part): part is string => typeof part === "string")
? value.parts.map((part) => /^\d+$/.test(part) ? Number(part) : part)
: null;
};
const nodeIdForDiagnostic = (
draft: DraftWorkspace,
parts: ReadonlyArray<string>,
): string | null => {
const nodeIndexPart = parts[parts.indexOf("nodes") + 1];
const nodeIndex = nodeIndexPart === undefined ? NaN : Number(nodeIndexPart);
if (!Number.isInteger(nodeIndex) || nodeIndex < 0 || !isRecord(draft.draft)) return null;
const nodes = draft.draft.nodes;
if (Array.isArray(nodes)) {
const node = nodes[nodeIndex];
return isRecord(node) && typeof node.id === "string" ? node.id : null;
}
const steps = draft.draft.steps;
if (!isRecord(steps)) return null;
const stepIds = Object.keys(steps).toSorted();
return stepIds[nodeIndex] ?? null;
};
const inputBindingsForStep = (
draft: DraftWorkspace,
stepId: string,
): ReadonlyArray<unknown> | null => {
if (!isRecord(draft.draft)) return null;
const steps = draft.draft.steps;
if (isRecord(steps) && isRecord(steps[stepId]) && Array.isArray(steps[stepId].input)) {
return steps[stepId].input;
}
const nodes = draft.draft.nodes;
if (!Array.isArray(nodes)) return null;
const node = nodes.find((candidate) => isRecord(candidate) && candidate.id === stepId);
return isRecord(node) && Array.isArray(node.input) ? node.input : null;
};
const fieldDiagnostic = (
diagnostic: DraftDiagnostic,
stepId: string,
draft: DraftWorkspace,
): SchemaValueIssue | null => {
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return null;
const parts = diagnosticParts(diagnostic.path);
const stepIndex = parts.findIndex((part) => part === stepId);
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return null;
const nodeIndex = parts.indexOf("nodes");
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return null;
const inputIndex = parts.indexOf("input");
if (inputIndex < 0) return null;
return { path: parts.slice(inputIndex + 1), message: diagnostic.message };
const inputPath = parts.slice(inputIndex + 1);
const bindingIndex = inputPath[0] === undefined ? NaN : Number(inputPath[0]);
if (Number.isInteger(bindingIndex) && bindingIndex >= 0) {
const bindings = inputBindingsForStep(draft, stepId);
const binding = bindings?.[bindingIndex];
if (!isRecord(binding)) return null;
const target = schemaPathParts(binding.target);
return target === null ? null : { path: target, message: diagnostic.message };
}
return { path: inputPath, message: diagnostic.message };
};
const metadataDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
stepId: string,
draft: DraftWorkspace,
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) return [];
const parts = diagnosticParts(diagnostic.path);
const stepIndex = parts.findIndex((part) => part === stepId);
if (stepIndex >= 0 && parts[stepIndex - 1] !== "steps" && parts[stepIndex - 1] !== "nodes") return [];
const nodeIndex = parts.indexOf("nodes");
const diagnosticStepId = diagnostic.stepId ?? (nodeIndex >= 0 ? nodeIdForDiagnostic(draft, parts) : null);
if (diagnosticStepId !== null && diagnosticStepId !== stepId) return [];
if (parts.includes("input")) return [];
const field = parts.at(-1);
return field === "desc" || field === "retry" || field === "timeout_seconds"
@@ -67,8 +135,9 @@ const metadataDiagnostics = (
const inputDiagnostics = (
diagnostics: ReadonlyArray<DraftDiagnostic>,
stepId: string,
draft: DraftWorkspace,
): ReadonlyArray<SchemaValueIssue> => diagnostics.flatMap((diagnostic) => {
const issue = fieldDiagnostic(diagnostic, stepId);
const issue = fieldDiagnostic(diagnostic, stepId, draft);
return issue === null ? [] : [issue];
});
@@ -236,6 +305,12 @@ export const ContextInspector = ({
);
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
const preservedForm =
controller.preservedCapabilityForm?.kind === "update" &&
controller.preservedCapabilityForm.input.stepId === selection.nodeId
? capabilityFormDataFromValue(controller.preservedCapabilityForm.input)
: null;
const formData = canonicalCapabilityFormData(draft, selection.nodeId) ?? preservedForm;
const unsupported = node?.data.kind === "unsupported";
content = (
<section className="authoring-inspector__selection" aria-labelledby="node-selection-heading">
@@ -243,7 +318,7 @@ export const ContextInspector = ({
<h2 id="node-selection-heading">{selection.nodeId}</h2>
<dl className="authoring-inspector__facts">
<Fact label="Kind" value={node?.data.kind ?? "unknown"} />
<Fact label="Reference" value={node?.data.nodeRef ?? "none"} />
<Fact label="Reference" value={node?.data.nodeRef ?? formData?.capabilityName ?? "none"} />
</dl>
{unsupported && <p role="status">Read-only: unsupported step kind.</p>}
{!unsupported && capabilityDetailPhase === "loading" && (
@@ -253,18 +328,17 @@ export const ContextInspector = ({
<p role="alert">{capabilityDetailMessage ?? "Capability schema failed to load."}</p>
)}
{!unsupported && capabilityDetailPhase === "ready" && capabilityDetail !== null && (() => {
const formData = canonicalCapabilityFormData(draft, selection.nodeId);
if (formData === null) return <p role="status">Canonical node data is unavailable.</p>;
return (
<CapabilityNodeForm
key={`node:${selection.nodeId}:${controller.resetGeneration}`}
capabilityName={formData.capabilityName}
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId)}
diagnostics={inputDiagnostics(draft.diagnostics, selection.nodeId, draft)}
initialInputSources={formData.initialInputSources}
initialInputValue={formData.initialInputValue}
initialValue={formData.initialValue}
inputSchema={capabilityDetail.inputSchema}
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId)}
metadataDiagnostics={metadataDiagnostics(draft.diagnostics, selection.nodeId, draft)}
onDirtyChange={controller.markDirty}
onSubmit={controller.updateCapability}
onValueChange={(value) => controller.rememberCapabilityForm("update", value)}
@@ -33,7 +33,12 @@ export const DraftWorkbench = ({
capabilityName = controller.selection.qualifiedName;
} else if (controller.selection.kind === "node") {
const nodeId = controller.selection.nodeId;
capabilityName = graph.nodes.find((node) => node.id === nodeId)?.data.nodeRef ?? null;
capabilityName =
graph.nodes.find((node) => node.id === nodeId)?.data.nodeRef ??
(controller.preservedCapabilityForm?.kind === "update" &&
controller.preservedCapabilityForm.input.stepId === nodeId
? controller.preservedCapabilityForm.input.capabilityName
: null);
}
const capabilityDetail = useAuthoringCapabilityDetail(capabilityName);
const select = useCallback(
@@ -1,6 +1,7 @@
import type {
CapabilityNodeFormValue,
} from "./CapabilityNodeForm.js";
import type { InputBinding, InputPath, LocalInputPath } 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";
@@ -67,6 +68,58 @@ const metadataValue = (step: JsonRecord, key: string): string | number | null |
return undefined;
};
const localInputPath = (value: unknown): value is LocalInputPath =>
typeof value === "string" || (
isRecord(value) &&
value.root === "local" &&
Array.isArray(value.parts) &&
value.parts.every((part): part is string => typeof part === "string")
);
const inputPath = (value: unknown): value is InputPath =>
typeof value === "string" || (
isRecord(value) &&
(value.root === "input" || value.root === "state" || value.root === "context") &&
Array.isArray(value.parts) &&
value.parts.every((part): part is string => typeof part === "string")
);
const inputBinding = (value: JsonRecord): InputBinding | 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 };
return null;
};
const formDataFromValue = (
input: CapabilityNodeFormValue,
): CanonicalCapabilityFormData => {
const initialInputSources: Record<string, FieldSources[string]> = {};
let initialInputValue: unknown = undefined;
for (const rawBinding of input.inputBindings ?? []) {
const target = localPath(rawBinding.target);
if (target === null) continue;
if ("path" in rawBinding) {
const path = sourcePath(rawBinding.path);
if (path !== null) initialInputSources[target] = { mode: "bind", sourcePath: path };
continue;
}
if ("value" in rawBinding) {
initialInputValue = setPath(initialInputValue, target, rawBinding.value);
}
}
return {
capabilityName: input.capabilityName,
initialValue: input,
initialInputValue,
initialInputSources,
};
};
export const capabilityFormDataFromValue = (
input: CapabilityNodeFormValue,
): CanonicalCapabilityFormData => formDataFromValue(input);
export const canonicalCapabilityFormData = (
draft: DraftWorkspace,
stepId: string,
@@ -88,29 +141,23 @@ export const canonicalCapabilityFormData = (
? { timeoutSeconds }
: {}),
} satisfies Partial<CapabilityNodeFormValue>;
const initialInputSources: Record<string, { readonly mode: "bind"; readonly sourcePath: string }> = {};
let initialInputValue: unknown = undefined;
const inputBindings: InputBinding[] = [];
const input = step.input;
if (Array.isArray(input)) {
for (const rawBinding of input) {
if (!isRecord(rawBinding)) continue;
const target = localPath(rawBinding.target);
if (target === null) continue;
if ("path" in rawBinding) {
const path = sourcePath(rawBinding.path);
if (path !== null) initialInputSources[target] = { mode: "bind", sourcePath: path };
continue;
}
if ("value" in rawBinding) {
initialInputValue = setPath(initialInputValue, target, rawBinding.value);
}
const binding = inputBinding(rawBinding);
if (binding !== null) inputBindings.push(binding);
}
}
return {
return formDataFromValue({
...initialValue,
stepId,
capabilityName,
initialValue,
initialInputValue,
initialInputSources,
};
description: initialValue.description ?? null,
retry: initialValue.retry ?? null,
timeoutSeconds: initialValue.timeoutSeconds ?? null,
inputBindings,
});
};
@@ -233,6 +233,30 @@ describe("useDraftAuthoring", () => {
});
});
it("preserves an update form when a conflict response has no draft", async () => {
const initial = workspace({
draft: { steps: { read: { use: "demo.read" } }, routes: {} },
});
const conflict = workspace({
revision: 4,
status: "conflict",
draft: null,
summary: { ...initial.summary, steps: [] },
});
authoringClient.updateCapabilityStep.mockResolvedValue(conflict);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "read" },
}));
const input = { ...capabilityInput, stepId: "read", capabilityName: "demo.read" };
await act(async () => result.current.updateCapability(input));
expect(result.current.draft).toBe(conflict);
expect(result.current.selection).toEqual({ kind: "node", nodeId: "read" });
expect(result.current.preservedCapabilityForm).toEqual({ kind: "update", input });
});
it("does not send different writes or validation concurrently for one revision", async () => {
const initial = workspace({ revision: 6 });
let resolveAdd: ((value: DraftWorkspace) => void) | undefined;
@@ -22,6 +22,7 @@ export interface DraftAuthoringController {
readonly phase: DraftAuthoringPhase;
readonly message: string | null;
readonly resetGeneration: number;
readonly preservedCapabilityForm: PreservedCapabilityForm;
readonly addCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly updateCapability: (input: CapabilityNodeFormValue) => Promise<void>;
readonly setRoute: (input: RouteFormValue) => Promise<void>;
@@ -71,6 +72,11 @@ type LastSubmission =
| { readonly kind: "route"; readonly input: RouteFormValue }
| null;
export type PreservedCapabilityForm =
| { readonly kind: "add"; readonly input: CapabilityNodeFormValue }
| { readonly kind: "update"; readonly input: CapabilityNodeFormValue }
| null;
const canvasSelection: WorkbenchSelection = { kind: "canvas" };
const errorMessage = (error: unknown): string =>
@@ -449,6 +455,10 @@ export const useDraftAuthoring = ({
validate,
reload,
reapply,
preservedCapabilityForm:
lastSubmissionRef.current?.kind === "add" || lastSubmissionRef.current?.kind === "update"
? lastSubmissionRef.current
: null,
rememberCapabilityForm,
rememberRouteForm,
select,