feat: project selected-step dataflow
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import { canonicalCapabilityFormData } from "./canonical-capability-form.js";
|
||||
|
||||
const draft = (step: Record<string, unknown>): DraftWorkspace => ({
|
||||
workspaceId: "draft-report",
|
||||
revision: 1,
|
||||
title: "Report",
|
||||
status: "valid",
|
||||
diagnostics: [],
|
||||
summary: {
|
||||
name: "report",
|
||||
start: "render",
|
||||
stepCount: 1,
|
||||
routeCount: 0,
|
||||
steps: ["render"],
|
||||
},
|
||||
draft: { steps: { render: step } },
|
||||
});
|
||||
|
||||
describe("canonical capability form projection", () => {
|
||||
it("preserves absent setup metadata instead of converting it to null", () => {
|
||||
const projected = canonicalCapabilityFormData(draft({ use: "wf.std.concat", retry: 0 }), "render");
|
||||
|
||||
expect(projected?.initialValue).toEqual({ stepId: "render", retry: 0 });
|
||||
expect(projected?.initialValue).not.toHaveProperty("description");
|
||||
expect(projected?.initialValue).not.toHaveProperty("timeoutSeconds");
|
||||
});
|
||||
|
||||
it("preserves explicit null setup metadata", () => {
|
||||
const projected = canonicalCapabilityFormData(
|
||||
draft({ use: "wf.std.concat", desc: null, retry: null, timeout_seconds: null }),
|
||||
"render",
|
||||
);
|
||||
|
||||
expect(projected?.initialValue).toEqual({
|
||||
stepId: "render",
|
||||
description: null,
|
||||
retry: null,
|
||||
timeoutSeconds: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,12 +91,13 @@ const inputBinding = (value: JsonRecord): InputBinding | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const formDataFromValue = (
|
||||
input: CapabilityNodeFormValue,
|
||||
): CanonicalCapabilityFormData => {
|
||||
const bindingFormData = (
|
||||
capabilityName: string,
|
||||
inputBindings: ReadonlyArray<InputBinding>,
|
||||
): Omit<CanonicalCapabilityFormData, "initialValue"> => {
|
||||
const initialInputSources: Record<string, FieldSources[string]> = {};
|
||||
let initialInputValue: unknown = undefined;
|
||||
for (const rawBinding of input.inputBindings ?? []) {
|
||||
for (const rawBinding of inputBindings) {
|
||||
const target = localPath(rawBinding.target);
|
||||
if (target === null) continue;
|
||||
if ("path" in rawBinding) {
|
||||
@@ -109,13 +110,27 @@ const formDataFromValue = (
|
||||
}
|
||||
}
|
||||
return {
|
||||
capabilityName: input.capabilityName,
|
||||
initialValue: input,
|
||||
capabilityName,
|
||||
initialInputValue,
|
||||
initialInputSources,
|
||||
};
|
||||
};
|
||||
|
||||
const formDataFromValue = (
|
||||
input: CapabilityNodeFormValue,
|
||||
): CanonicalCapabilityFormData => ({
|
||||
...bindingFormData(input.capabilityName, input.inputBindings ?? []),
|
||||
initialValue: input,
|
||||
});
|
||||
|
||||
const formDataFromBindings = (
|
||||
capabilityName: string,
|
||||
inputBindings: ReadonlyArray<InputBinding>,
|
||||
initialValue: Partial<CapabilityNodeFormValue>,
|
||||
): CanonicalCapabilityFormData => {
|
||||
return { ...bindingFormData(capabilityName, inputBindings), initialValue };
|
||||
};
|
||||
|
||||
export const capabilityFormDataFromValue = (
|
||||
input: CapabilityNodeFormValue,
|
||||
): CanonicalCapabilityFormData => formDataFromValue(input);
|
||||
@@ -151,13 +166,5 @@ export const canonicalCapabilityFormData = (
|
||||
}
|
||||
}
|
||||
|
||||
return formDataFromValue({
|
||||
...initialValue,
|
||||
stepId,
|
||||
capabilityName,
|
||||
description: initialValue.description ?? null,
|
||||
retry: initialValue.retry ?? null,
|
||||
timeoutSeconds: initialValue.timeoutSeconds ?? null,
|
||||
inputBindings,
|
||||
});
|
||||
return formDataFromBindings(capabilityName, inputBindings, { ...initialValue, stepId });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
|
||||
import {
|
||||
bindingDiagnosticsForStep,
|
||||
capabilityLocalPathSuggestions,
|
||||
inferredStateSchemaPreview,
|
||||
inputBindingRows,
|
||||
outputBindingRows,
|
||||
projectSelectedStepDataflow,
|
||||
serializeInputBindingRow,
|
||||
serializeInputBindingRows,
|
||||
serializeOutputBindingRow,
|
||||
serializeOutputBindingRows,
|
||||
stateTargetSuggestions,
|
||||
workflowSourceSuggestions,
|
||||
} from "./selected-step-dataflow.js";
|
||||
|
||||
const summary = {
|
||||
name: "report",
|
||||
start: "render",
|
||||
stepCount: 1,
|
||||
routeCount: 0,
|
||||
steps: ["render"],
|
||||
};
|
||||
|
||||
const bindings = {
|
||||
input: [
|
||||
{ path: "input.items", target: "items" },
|
||||
{ target: "separator", value: null },
|
||||
{ path: "state.fallback", target: "fallback" },
|
||||
],
|
||||
output: [
|
||||
{ source: "text", target: "state.report" },
|
||||
{ source: "text", target: "state.audit.latest" },
|
||||
],
|
||||
};
|
||||
|
||||
const keyedDraft: DraftWorkspace = {
|
||||
workspaceId: "draft-report",
|
||||
revision: 3,
|
||||
title: "Report",
|
||||
status: "valid",
|
||||
diagnostics: [],
|
||||
summary,
|
||||
draft: {
|
||||
steps: {
|
||||
render: {
|
||||
use: "wf.std.concat",
|
||||
...bindings,
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const compiledDraft: DraftWorkspace = {
|
||||
...keyedDraft,
|
||||
draft: { nodes: [{ id: "render", type: "node", node: "wf.std.concat", ...bindings, retry: 0 }] },
|
||||
};
|
||||
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { type: "array", items: { type: "string" } },
|
||||
account: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const diagnostic = (
|
||||
path: string,
|
||||
stepId: string | null = null,
|
||||
): DraftDiagnostic => ({
|
||||
code: `diagnostic-${path}`,
|
||||
path,
|
||||
message: `Issue at ${path}`,
|
||||
stepId,
|
||||
repairHint: null,
|
||||
details: {},
|
||||
});
|
||||
|
||||
describe("selected-step dataflow projection", () => {
|
||||
it("projects keyed and compiled drafts to the same ordered canonical dataflow", () => {
|
||||
const keyed = projectSelectedStepDataflow(keyedDraft, "render");
|
||||
const compiled = projectSelectedStepDataflow(compiledDraft, "render");
|
||||
|
||||
expect(keyed).toEqual({
|
||||
stepId: "render",
|
||||
capabilityName: "wf.std.concat",
|
||||
description: undefined,
|
||||
retry: 0,
|
||||
timeoutSeconds: undefined,
|
||||
inputs: bindings.input,
|
||||
outputs: bindings.output,
|
||||
unsupported: [],
|
||||
});
|
||||
expect(compiled).toEqual(keyed);
|
||||
});
|
||||
|
||||
it("accepts structural paths, whole-payload paths, empty lists, and reports malformed rows", () => {
|
||||
const draft: DraftWorkspace = {
|
||||
...keyedDraft,
|
||||
draft: {
|
||||
steps: {
|
||||
render: {
|
||||
use: "wf.std.concat",
|
||||
input: [
|
||||
{
|
||||
path: { root: "input", parts: ["items"] },
|
||||
target: { root: "local", parts: [] },
|
||||
},
|
||||
{ target: "broken", path: { root: "input", parts: [""] } },
|
||||
{ target: "literal", value: null },
|
||||
null,
|
||||
],
|
||||
output: [
|
||||
{ source: { root: "local", parts: [] }, target: { root: "state", parts: ["report"] } },
|
||||
{ source: "text", target: "state" },
|
||||
{ source: "text", target: "state." },
|
||||
{ source: "text", target: "state.audit..latest" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const projected = projectSelectedStepDataflow(draft, "render");
|
||||
expect(projected?.inputs).toEqual([
|
||||
{ path: { root: "input", parts: ["items"] }, target: { root: "local", parts: [] } },
|
||||
{ target: "literal", value: null },
|
||||
]);
|
||||
expect(projected?.outputs).toEqual([
|
||||
{ source: { root: "local", parts: [] }, target: { root: "state", parts: ["report"] } },
|
||||
]);
|
||||
expect(projected?.unsupported).toEqual([
|
||||
expect.objectContaining({ field: "input", index: 1, raw: draft.draft && expect.anything() }),
|
||||
expect.objectContaining({ field: "input", index: 3 }),
|
||||
expect.objectContaining({ field: "output", index: 1 }),
|
||||
expect.objectContaining({ field: "output", index: 2 }),
|
||||
expect.objectContaining({ field: "output", index: 3 }),
|
||||
]);
|
||||
expect(projected?.unsupported.every((row) => row.reason.length > 0)).toBe(true);
|
||||
|
||||
expect(projectSelectedStepDataflow({ ...keyedDraft, draft: { steps: { render: { use: "x", input: [], output: [] } } } }, "render")).toEqual({
|
||||
stepId: "render",
|
||||
capabilityName: "x",
|
||||
description: undefined,
|
||||
retry: undefined,
|
||||
timeoutSeconds: undefined,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
unsupported: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a missing step", () => {
|
||||
expect(projectSelectedStepDataflow(keyedDraft, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps unsupported rows at their original positions for explicit repair", () => {
|
||||
const rawInput = [
|
||||
{ path: "input.first", target: "first" },
|
||||
{ target: "broken", value: undefined },
|
||||
{ path: "input.third", target: "third" },
|
||||
];
|
||||
const rawOutput = [
|
||||
{ source: "first", target: "state.first" },
|
||||
{ source: "broken", target: "state" },
|
||||
{ source: "third", target: "state.third" },
|
||||
];
|
||||
|
||||
expect(inputBindingRows(rawInput)).toEqual([
|
||||
{ kind: "canonical", index: 0, value: rawInput[0] },
|
||||
expect.objectContaining({ kind: "unsupported", index: 1, raw: rawInput[1] }),
|
||||
{ kind: "canonical", index: 2, value: rawInput[2] },
|
||||
]);
|
||||
expect(outputBindingRows(rawOutput)).toEqual([
|
||||
{ kind: "canonical", index: 0, value: rawOutput[0] },
|
||||
expect.objectContaining({ kind: "unsupported", index: 1, raw: rawOutput[1] }),
|
||||
{ kind: "canonical", index: 2, value: rawOutput[2] },
|
||||
]);
|
||||
|
||||
const inputRows = inputBindingRows(rawInput);
|
||||
const outputRows = outputBindingRows(rawOutput);
|
||||
expect(serializeInputBindingRows(inputRows)).toBeNull();
|
||||
expect(serializeOutputBindingRows(outputRows)).toBeNull();
|
||||
expect(serializeInputBindingRows(inputRows.filter((row) => row.kind === "canonical"))).toEqual([
|
||||
rawInput[0],
|
||||
rawInput[2],
|
||||
]);
|
||||
expect(serializeOutputBindingRows(outputRows.filter((row) => row.kind === "canonical"))).toEqual([
|
||||
rawOutput[0],
|
||||
rawOutput[2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes canonical strings and rejects invalid output state targets", () => {
|
||||
expect(serializeInputBindingRow({
|
||||
path: { root: "input", parts: ["items"] },
|
||||
target: { root: "local", parts: [] },
|
||||
})).toEqual({ path: "input.items", target: "." });
|
||||
expect(serializeInputBindingRow({ target: "separator", value: null })).toEqual({
|
||||
target: "separator",
|
||||
value: null,
|
||||
});
|
||||
expect(serializeOutputBindingRow({
|
||||
source: { root: "local", parts: [] },
|
||||
target: { root: "state", parts: ["audit", "latest"] },
|
||||
})).toEqual({ source: ".", target: "state.audit.latest" });
|
||||
|
||||
for (const target of ["state", "state.", "state.audit..latest", { root: "state", parts: [] }]) {
|
||||
expect(serializeOutputBindingRow({ source: "text", target })).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected-step schema helpers", () => {
|
||||
it("returns canonical schema path suggestions", () => {
|
||||
expect(workflowSourceSuggestions(schema, {
|
||||
type: "object",
|
||||
properties: { fallback: { type: "string" } },
|
||||
})).toEqual([
|
||||
"input.items",
|
||||
"input.items.0",
|
||||
"input.account",
|
||||
"input.account.name",
|
||||
"state.fallback",
|
||||
]);
|
||||
expect(capabilityLocalPathSuggestions(schema)).toEqual([
|
||||
".",
|
||||
"items",
|
||||
"items.0",
|
||||
"account",
|
||||
"account.name",
|
||||
]);
|
||||
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]);
|
||||
});
|
||||
|
||||
it("previews only the selected output source schema", () => {
|
||||
const outputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
audit: { type: "object", properties: { latest: { type: "integer" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(inferredStateSchemaPreview(outputSchema, "text", "state.report")).toEqual({ type: "string" });
|
||||
expect(inferredStateSchemaPreview(outputSchema, "audit.latest", "state.audit.latest")).toEqual({ type: "integer" });
|
||||
expect(inferredStateSchemaPreview(outputSchema, ".", "state.report")).toEqual(outputSchema);
|
||||
expect(inferredStateSchemaPreview(outputSchema, "missing", "state.report")).toBeNull();
|
||||
expect(inferredStateSchemaPreview(outputSchema, "text", "state")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected-step binding diagnostics", () => {
|
||||
it("maps only structural row paths and leaves unmatched diagnostics shared", () => {
|
||||
const stepId = "render/one~x";
|
||||
const diagnostics = [
|
||||
diagnostic("nodes[2].input[1].target", stepId),
|
||||
diagnostic("nodes[2].output[3].target", stepId),
|
||||
diagnostic("/steps/render~1one~0x/input/4/path", null),
|
||||
diagnostic("/steps/render~1one~0x/output/5/target", stepId),
|
||||
diagnostic("bindings[6].target", stepId),
|
||||
diagnostic("/steps/render~1one~0x/input", stepId),
|
||||
diagnostic("nodes[4].input[7].target", stepId),
|
||||
diagnostic("bindings[8].target", null),
|
||||
diagnostic("not a row location", stepId),
|
||||
diagnostic("/steps/other/input/9/path", stepId),
|
||||
];
|
||||
|
||||
const inputs = bindingDiagnosticsForStep(diagnostics, stepId, "input");
|
||||
expect(inputs.rowIssues[1]?.[0]?.path).toBe("nodes[2].input[1].target");
|
||||
expect(inputs.rowIssues[4]?.[0]?.path).toBe("/steps/render~1one~0x/input/4/path");
|
||||
expect(inputs.rowIssues[7]).toBeUndefined();
|
||||
expect(inputs.unmatchedIssues.map((item) => item.path)).toEqual([
|
||||
"nodes[2].output[3].target",
|
||||
"/steps/render~1one~0x/output/5/target",
|
||||
"/steps/render~1one~0x/input",
|
||||
"nodes[4].input[7].target",
|
||||
"bindings[8].target",
|
||||
"not a row location",
|
||||
"/steps/other/input/9/path",
|
||||
]);
|
||||
|
||||
const outputs = bindingDiagnosticsForStep(diagnostics, stepId, "output");
|
||||
expect(outputs.rowIssues[3]?.[0]?.path).toBe("nodes[2].output[3].target");
|
||||
expect(outputs.rowIssues[5]?.[0]?.path).toBe("/steps/render~1one~0x/output/5/target");
|
||||
expect(outputs.rowIssues[6]?.[0]?.path).toBe("bindings[6].target");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import type {
|
||||
DraftDiagnostic,
|
||||
DraftWorkspace,
|
||||
InputBinding,
|
||||
InputPath,
|
||||
LocalInputPath,
|
||||
OutputBinding,
|
||||
StatePath,
|
||||
} from "../domain/draft-workspace-models.js";
|
||||
import { formatTOMLPath, parseTOMLPath } from "../schema-form/schema-paths.js";
|
||||
import { normalizeSchema, type SchemaField } from "../schema-form/schema-field.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const isRecord = (value: unknown): value is JsonRecord =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const hasOwn = (value: JsonRecord, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
const stringParts = (value: unknown): string[] | null => {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const part of value) {
|
||||
if (typeof part !== "string") return null;
|
||||
parts.push(part);
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const validPathParts = (parts: ReadonlyArray<string>): boolean => {
|
||||
if (parts.some((part) => part.length === 0)) return false;
|
||||
const formatted = formatTOMLPath(parts);
|
||||
return parseTOMLPath(formatted) !== null;
|
||||
};
|
||||
|
||||
const localPathParts = (value: unknown): string[] | null => {
|
||||
if (typeof value === "string") {
|
||||
const parts = parseTOMLPath(value);
|
||||
return parts === null ? null : [...parts];
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "local") return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && validPathParts(parts) ? parts : null;
|
||||
};
|
||||
|
||||
const inputPathParts = (value: unknown): string[] | null => {
|
||||
if (typeof value === "string") {
|
||||
const parts = parseTOMLPath(value);
|
||||
return parts !== null &&
|
||||
parts.length > 0 &&
|
||||
(parts[0] === "input" || parts[0] === "state" || parts[0] === "context")
|
||||
? [...parts]
|
||||
: null;
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
if (value.root !== "input" && value.root !== "state" && value.root !== "context") return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && validPathParts(parts) ? [value.root, ...parts] : null;
|
||||
};
|
||||
|
||||
const statePathParts = (value: unknown): string[] | null => {
|
||||
if (typeof value === "string") {
|
||||
const parts = parseTOMLPath(value);
|
||||
return parts !== null && parts.length > 1 && parts[0] === "state" ? [...parts] : null;
|
||||
}
|
||||
if (!isRecord(value) || value.root !== "state") return null;
|
||||
const parts = stringParts(value.parts);
|
||||
return parts !== null && parts.length > 0 && validPathParts(parts) ? ["state", ...parts] : null;
|
||||
};
|
||||
|
||||
const inputPath = (value: unknown): InputPath | null => {
|
||||
const parts = inputPathParts(value);
|
||||
if (parts === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (!isRecord(value)) return null;
|
||||
const root = value.root;
|
||||
if (root !== "input" && root !== "state" && root !== "context") return null;
|
||||
return { root, parts: parts.slice(1) };
|
||||
};
|
||||
|
||||
const localPath = (value: unknown): LocalInputPath | null => {
|
||||
const parts = localPathParts(value);
|
||||
if (parts === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
return { root: "local", parts: [...parts] };
|
||||
};
|
||||
|
||||
const statePath = (value: unknown): StatePath | null => {
|
||||
const parts = statePathParts(value);
|
||||
if (parts === null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
return { root: "state", parts: parts.slice(1) };
|
||||
};
|
||||
|
||||
const canonicalLocalPath = (value: unknown): string | null => {
|
||||
const parts = localPathParts(value);
|
||||
return parts === null ? null : formatTOMLPath(parts);
|
||||
};
|
||||
|
||||
const canonicalInputPath = (value: unknown): string | null => {
|
||||
const parts = inputPathParts(value);
|
||||
return parts === null ? null : formatTOMLPath(parts);
|
||||
};
|
||||
|
||||
const canonicalStatePath = (value: unknown): string | null => {
|
||||
const parts = statePathParts(value);
|
||||
return parts === null ? null : formatTOMLPath(parts);
|
||||
};
|
||||
|
||||
const parsedInputBinding = (value: unknown): InputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const target = localPath(value.target);
|
||||
if (target === null) return null;
|
||||
const hasPath = hasOwn(value, "path");
|
||||
const hasValue = hasOwn(value, "value");
|
||||
if (hasPath === hasValue) return null;
|
||||
if (hasPath) {
|
||||
const path = inputPath(value.path);
|
||||
return path === null ? null : { path, target };
|
||||
}
|
||||
return value.value === undefined ? null : { target, value: value.value };
|
||||
};
|
||||
|
||||
const parsedOutputBinding = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const source = localPath(value.source);
|
||||
const target = statePath(value.target);
|
||||
return source === null || target === null ? null : { source, target };
|
||||
};
|
||||
|
||||
const unsupportedReason = (field: "input" | "output", index: number): string =>
|
||||
`Unsupported ${field} binding at index ${index}: the row is not a canonical ${field} binding.`;
|
||||
|
||||
type ParsedRows<T> = {
|
||||
readonly values: ReadonlyArray<T>;
|
||||
readonly unsupported: ReadonlyArray<UnsupportedBindingRow>;
|
||||
};
|
||||
|
||||
const parseRows = <T>(
|
||||
field: "input" | "output",
|
||||
raw: unknown,
|
||||
parse: (value: unknown) => T | null,
|
||||
): ParsedRows<T> => {
|
||||
const values: T[] = [];
|
||||
const unsupported: UnsupportedBindingRow[] = [];
|
||||
const rows = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
rows.forEach((value, index) => {
|
||||
const parsed = parse(value);
|
||||
if (parsed === null) {
|
||||
unsupported.push({ field, index, raw: value, reason: unsupportedReason(field, index) });
|
||||
} else {
|
||||
values.push(parsed);
|
||||
}
|
||||
});
|
||||
return { values, unsupported };
|
||||
};
|
||||
|
||||
const stepFromDraft = (draft: DraftWorkspace, stepId: string): JsonRecord | null => {
|
||||
if (!isRecord(draft.draft)) return null;
|
||||
if (Array.isArray(draft.draft.nodes)) {
|
||||
for (const node of draft.draft.nodes) {
|
||||
if (isRecord(node) && node.id === stepId) return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const steps = draft.draft.steps;
|
||||
if (!isRecord(steps) || !isRecord(steps[stepId])) return null;
|
||||
return steps[stepId];
|
||||
};
|
||||
|
||||
/** Compound canonical projection used by the selected-step setup, input, and output forms. */
|
||||
export type SelectedStepDataflow = {
|
||||
readonly stepId: string;
|
||||
readonly capabilityName: string;
|
||||
readonly description: string | null | undefined;
|
||||
readonly retry: number | null | undefined;
|
||||
readonly timeoutSeconds: number | null | undefined;
|
||||
readonly inputs: ReadonlyArray<InputBinding>;
|
||||
readonly outputs: ReadonlyArray<OutputBinding>;
|
||||
readonly unsupported: ReadonlyArray<UnsupportedBindingRow>;
|
||||
};
|
||||
|
||||
export type UnsupportedBindingRow = {
|
||||
readonly field: "input" | "output";
|
||||
readonly index: number;
|
||||
readonly raw: unknown;
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
export type CapabilitySetupPatch = {
|
||||
readonly description?: string | null;
|
||||
readonly retry?: number | null;
|
||||
readonly timeoutSeconds?: number | null;
|
||||
};
|
||||
|
||||
export type BindingRow<T> =
|
||||
| { readonly kind: "canonical"; readonly index: number; readonly value: T }
|
||||
| UnsupportedBindingRow & { readonly kind: "unsupported" };
|
||||
|
||||
export type InputBindingRow = BindingRow<InputBinding>;
|
||||
export type OutputBindingRow = BindingRow<OutputBinding>;
|
||||
|
||||
export const projectSelectedStepDataflow = (
|
||||
draft: DraftWorkspace,
|
||||
stepId: string,
|
||||
): SelectedStepDataflow | null => {
|
||||
const step = stepFromDraft(draft, stepId);
|
||||
if (step === null) return null;
|
||||
const capabilityName = typeof step.use === "string" ? step.use : step.node;
|
||||
if (typeof capabilityName !== "string" || capabilityName.length === 0) return null;
|
||||
const inputs = parseRows("input", step.input, parsedInputBinding);
|
||||
const outputs = parseRows("output", step.output, parsedOutputBinding);
|
||||
const description = step.desc === null || typeof step.desc === "string" ? step.desc : undefined;
|
||||
const retry = step.retry === null || typeof step.retry === "number" ? step.retry : undefined;
|
||||
const timeoutSeconds =
|
||||
step.timeout_seconds === null || typeof step.timeout_seconds === "number"
|
||||
? step.timeout_seconds
|
||||
: undefined;
|
||||
return {
|
||||
stepId,
|
||||
capabilityName,
|
||||
description,
|
||||
retry,
|
||||
timeoutSeconds,
|
||||
inputs: inputs.values,
|
||||
outputs: outputs.values,
|
||||
unsupported: [...inputs.unsupported, ...outputs.unsupported],
|
||||
};
|
||||
};
|
||||
|
||||
const rowsFor = <T>(
|
||||
raw: unknown,
|
||||
parse: (value: unknown) => T | null,
|
||||
field: "input" | "output",
|
||||
): ReadonlyArray<BindingRow<T>> => {
|
||||
const rows: Array<BindingRow<T>> = [];
|
||||
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
values.forEach((value, index) => {
|
||||
const parsed = parse(value);
|
||||
if (parsed === null) {
|
||||
rows.push({ kind: "unsupported", field, index, raw: value, reason: unsupportedReason(field, index) });
|
||||
} else {
|
||||
rows.push({ kind: "canonical", index, value: parsed });
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const inputBindingRows = (raw: unknown): ReadonlyArray<InputBindingRow> =>
|
||||
rowsFor(raw, parsedInputBinding, "input");
|
||||
|
||||
export const outputBindingRows = (raw: unknown): ReadonlyArray<OutputBindingRow> =>
|
||||
rowsFor(raw, parsedOutputBinding, "output");
|
||||
|
||||
export const serializeInputBindingRow = (value: unknown): InputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const target = canonicalLocalPath(value.target);
|
||||
if (target === null) return null;
|
||||
if (hasOwn(value, "path") && !hasOwn(value, "value")) {
|
||||
const path = canonicalInputPath(value.path);
|
||||
return path === null ? null : { path, target };
|
||||
}
|
||||
if (!hasOwn(value, "path") && hasOwn(value, "value") && value.value !== undefined) {
|
||||
return { target, value: value.value };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const serializeInputBindingRows = (
|
||||
rows: ReadonlyArray<InputBindingRow>,
|
||||
): ReadonlyArray<InputBinding> | null => {
|
||||
const bindings: InputBinding[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "unsupported") return null;
|
||||
const binding = serializeInputBindingRow(row.value);
|
||||
if (binding === null) return null;
|
||||
bindings.push(binding);
|
||||
}
|
||||
return bindings;
|
||||
};
|
||||
|
||||
export const serializeOutputBindingRow = (value: unknown): OutputBinding | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const source = canonicalLocalPath(value.source);
|
||||
const target = canonicalStatePath(value.target);
|
||||
return source === null || target === null ? null : { source, target };
|
||||
};
|
||||
|
||||
export const serializeOutputBindingRows = (
|
||||
rows: ReadonlyArray<OutputBindingRow>,
|
||||
): ReadonlyArray<OutputBinding> | null => {
|
||||
const bindings: OutputBinding[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "unsupported") return null;
|
||||
const binding = serializeOutputBindingRow(row.value);
|
||||
if (binding === null) return null;
|
||||
bindings.push(binding);
|
||||
}
|
||||
return bindings;
|
||||
};
|
||||
|
||||
const descendantPaths = (field: SchemaField): ReadonlyArray<ReadonlyArray<string | number>> => {
|
||||
const paths: Array<ReadonlyArray<string | number>> = [];
|
||||
for (const child of field.children) {
|
||||
paths.push(child.path, ...descendantPaths(child));
|
||||
}
|
||||
if (field.item !== null) paths.push(field.item.path, ...descendantPaths(field.item));
|
||||
return paths;
|
||||
};
|
||||
|
||||
const schemaPathStrings = (schema: unknown): ReadonlyArray<string> => {
|
||||
const field = normalizeSchema(schema);
|
||||
return descendantPaths(field).map((path) => formatTOMLPath(path));
|
||||
};
|
||||
|
||||
const prefixedSchemaPaths = (prefix: string, schema: unknown): ReadonlyArray<string> =>
|
||||
schemaPathStrings(schema).flatMap((path) => {
|
||||
const parts = parseTOMLPath(path);
|
||||
return parts === null ? [] : [formatTOMLPath([prefix, ...parts])];
|
||||
});
|
||||
|
||||
export const workflowSourceSuggestions = (
|
||||
inputSchema: unknown,
|
||||
stateSchema: unknown,
|
||||
): ReadonlyArray<string> => [
|
||||
...prefixedSchemaPaths("input", inputSchema),
|
||||
...prefixedSchemaPaths("state", stateSchema),
|
||||
];
|
||||
|
||||
export const capabilityLocalPathSuggestions = (capabilitySchema: unknown): ReadonlyArray<string> => [
|
||||
".",
|
||||
...schemaPathStrings(capabilitySchema),
|
||||
];
|
||||
|
||||
export const stateTargetSuggestions = (stateSchema: unknown): ReadonlyArray<string> =>
|
||||
prefixedSchemaPaths("state", stateSchema);
|
||||
|
||||
const schemaAtPath = (schema: unknown, parts: ReadonlyArray<string>): unknown | null => {
|
||||
let current: unknown = schema;
|
||||
for (const part of parts) {
|
||||
if (!isRecord(current)) return null;
|
||||
if (current.type === "object" && isRecord(current.properties)) {
|
||||
current = current.properties[part];
|
||||
continue;
|
||||
}
|
||||
if (current.type === "array" && current.items !== undefined && part === "0") {
|
||||
current = current.items;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return current === undefined ? null : current;
|
||||
};
|
||||
|
||||
export const inferredStateSchemaPreview = (
|
||||
outputSchema: unknown,
|
||||
source: LocalInputPath,
|
||||
target: StatePath,
|
||||
): unknown | null => {
|
||||
if (statePathParts(target) === null) return null;
|
||||
const sourceParts = localPathParts(source);
|
||||
if (sourceParts === null) return null;
|
||||
return schemaAtPath(outputSchema, sourceParts);
|
||||
};
|
||||
|
||||
export type BindingDiagnostics = {
|
||||
readonly rowIssues: Readonly<Record<number, ReadonlyArray<DraftDiagnostic>>>;
|
||||
readonly unmatchedIssues: ReadonlyArray<DraftDiagnostic>;
|
||||
};
|
||||
|
||||
type RowLocation = { readonly field: "input" | "output"; readonly index: number };
|
||||
|
||||
const nodeLocation = (path: string): { readonly index: number; readonly field: "input" | "output"; readonly bindingIndex: number } | null => {
|
||||
const match = /^nodes\[(\d+)\]\.(input|output)\[(\d+)\](?:\.|$)/.exec(path);
|
||||
if (match === null) return null;
|
||||
const nodeIndex = Number(match[1]);
|
||||
const bindingIndex = Number(match[3]);
|
||||
return Number.isInteger(nodeIndex) && Number.isInteger(bindingIndex)
|
||||
? { index: nodeIndex, field: match[2] === "input" ? "input" : "output", bindingIndex }
|
||||
: null;
|
||||
};
|
||||
|
||||
const pointerLocation = (path: string): RowLocation | null => {
|
||||
if (!path.startsWith("/")) return null;
|
||||
const parts = path.split("/").slice(1).map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
|
||||
const field = parts[2];
|
||||
const index = parts[3];
|
||||
if (parts[0] !== "steps" || (field !== "input" && field !== "output") || index === undefined || !/^\d+$/.test(index)) return null;
|
||||
return { field, index: Number(index) };
|
||||
};
|
||||
|
||||
const focusedLocation = (
|
||||
path: string,
|
||||
diagnostic: DraftDiagnostic,
|
||||
stepId: string,
|
||||
field: "input" | "output",
|
||||
): RowLocation | null => {
|
||||
if (diagnostic.stepId !== stepId) return null;
|
||||
const match = /^bindings\[(\d+)\](?:\.|$)/.exec(path);
|
||||
return match === null ? null : { field, index: Number(match[1]) };
|
||||
};
|
||||
|
||||
export const bindingDiagnosticsForStep = (
|
||||
diagnostics: ReadonlyArray<DraftDiagnostic>,
|
||||
stepId: string,
|
||||
field: "input" | "output",
|
||||
): BindingDiagnostics => {
|
||||
const selectedNodeIndex = diagnostics
|
||||
.map((diagnostic) => diagnostic.stepId === stepId ? nodeLocation(diagnostic.path) : null)
|
||||
.find((location) => location !== null)?.index;
|
||||
const rowIssues: Record<number, DraftDiagnostic[]> = {};
|
||||
const unmatchedIssues: DraftDiagnostic[] = [];
|
||||
for (const diagnostic of diagnostics) {
|
||||
if (diagnostic.stepId !== null && diagnostic.stepId !== stepId) {
|
||||
unmatchedIssues.push(diagnostic);
|
||||
continue;
|
||||
}
|
||||
const node = nodeLocation(diagnostic.path);
|
||||
const pointer = pointerLocation(diagnostic.path);
|
||||
const focused = focusedLocation(diagnostic.path, diagnostic, stepId, field);
|
||||
const location = node !== null && node.index === selectedNodeIndex && node.field === field
|
||||
? { field: node.field, index: node.bindingIndex }
|
||||
: pointer !== null && pointer.field === field && diagnostic.path.split("/").slice(1)[1]?.replaceAll("~1", "/").replaceAll("~0", "~") === stepId
|
||||
? pointer
|
||||
: focused !== null
|
||||
? focused
|
||||
: null;
|
||||
if (location === null) {
|
||||
unmatchedIssues.push(diagnostic);
|
||||
continue;
|
||||
}
|
||||
const existing = rowIssues[location.index] ?? [];
|
||||
rowIssues[location.index] = [...existing, diagnostic];
|
||||
}
|
||||
return { rowIssues, unmatchedIssues };
|
||||
};
|
||||
@@ -28,6 +28,29 @@ export type InputValueBinding = {
|
||||
|
||||
export type InputBinding = InputPathBinding | InputValueBinding;
|
||||
|
||||
export type StatePath =
|
||||
| string
|
||||
| { readonly root: "state"; readonly parts: string[] };
|
||||
|
||||
export type OutputBinding = {
|
||||
readonly source: LocalInputPath;
|
||||
readonly target: StatePath;
|
||||
};
|
||||
|
||||
export type SetStepInputBindingsInput = {
|
||||
readonly workspaceId: string;
|
||||
readonly revision: number;
|
||||
readonly stepId: string;
|
||||
readonly bindings: ReadonlyArray<InputBinding>;
|
||||
};
|
||||
|
||||
export type SetStepOutputBindingsInput = {
|
||||
readonly workspaceId: string;
|
||||
readonly revision: number;
|
||||
readonly stepId: string;
|
||||
readonly bindings: ReadonlyArray<OutputBinding>;
|
||||
};
|
||||
|
||||
export type CreateEmptyDraftInput = {
|
||||
readonly workspaceId: string;
|
||||
readonly name: string;
|
||||
|
||||
Reference in New Issue
Block a user