feat(core): workflow IR typed settings declarations with built-in moved-key catalog

This commit is contained in:
gsxdsm
2026-06-04 22:00:04 -07:00
parent 12a3a03524
commit 04d5e66d24
7 changed files with 719 additions and 2 deletions

View File

@@ -0,0 +1,229 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import type {
WorkflowIrV2,
WorkflowIrNode,
WorkflowSettingDefinition,
} from "../workflow-ir-types.js";
const startEnd: WorkflowIrNode[] = [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
];
function withSettings(settings: WorkflowSettingDefinition[]): WorkflowIrV2 {
return {
version: "v2",
name: "test",
columns: [],
nodes: startEnd,
edges: [{ from: "start", to: "end" }],
settings,
};
}
describe("parseWorkflowIr — workflow settings declarations (U1)", () => {
it("parses and round-trips a valid declaration of each type", () => {
const settings: WorkflowSettingDefinition[] = [
{ id: "s-string", name: "S", type: "string", default: "x" },
{ id: "s-text", name: "T", type: "text", default: "long" },
{ id: "s-number", name: "N", type: "number", default: 42 },
{ id: "s-boolean", name: "B", type: "boolean", default: true },
{
id: "s-enum",
name: "E",
type: "enum",
default: "a",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
{
id: "s-multi",
name: "M",
type: "multi-enum",
default: ["a"],
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
render: { widget: "chips" },
},
];
const parsed = parseWorkflowIr(withSettings(settings)) as WorkflowIrV2;
expect(parsed.settings).toEqual(settings);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
});
it("allows a declaration with no default and a description", () => {
const parsed = parseWorkflowIr(
withSettings([
{ id: "lane", name: "Lane", type: "string", description: "a model lane" },
]),
) as WorkflowIrV2;
expect(parsed.settings?.[0].default).toBeUndefined();
});
it("rejects duplicate setting ids", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "dup", name: "A", type: "string" },
{ id: "dup", name: "B", type: "string" },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects an empty id", () => {
expect(() =>
parseWorkflowIr(withSettings([{ id: "", name: "A", type: "string" }])),
).toThrow(WorkflowIrError);
});
it("rejects an unknown type", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "date" as never }]),
),
).toThrow(WorkflowIrError);
});
it("rejects an enum without options", () => {
expect(() =>
parseWorkflowIr(withSettings([{ id: "x", name: "A", type: "enum" }])),
).toThrow(WorkflowIrError);
});
it("rejects options on a non-enum type", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "x", name: "A", type: "number", options: [{ value: "a", label: "A" }] },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects duplicate option values", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "enum",
options: [
{ value: "a", label: "A" },
{ value: "a", label: "A2" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a disallowed render widget", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{ id: "x", name: "A", type: "string", render: { widget: "slider" as never } },
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a default violating its own type (number with string)", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "number", default: "x" }]),
),
).toThrow(WorkflowIrError);
});
it("rejects a default violating boolean type", () => {
expect(() =>
parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "boolean", default: "true" }]),
),
).toThrow(WorkflowIrError);
});
it("rejects an enum default not among options", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "enum",
default: "c",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("rejects a multi-enum default containing an unknown option", () => {
expect(() =>
parseWorkflowIr(
withSettings([
{
id: "x",
name: "A",
type: "multi-enum",
default: ["a", "c"],
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
]),
),
).toThrow(WorkflowIrError);
});
it("does not downgrade an IR with settings present to v1", () => {
const parsed = parseWorkflowIr(
withSettings([{ id: "x", name: "A", type: "string", default: "v" }]),
);
const down = downgradeIrToV1IfPure(parsed);
expect(down.version).toBe("v2");
});
});
describe("built-in workflow settings parity anchor (U1, R4)", () => {
it("the built-in coding workflow declares the full moved-key catalog", () => {
const builtin = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2;
const declaredIds = new Set((builtin.settings ?? []).map((s) => s.id));
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
expect(declaredIds.has(setting.id)).toBe(true);
}
expect(builtin.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
});
it("each declaration default strictly equals the legacy DEFAULT_PROJECT_SETTINGS literal", () => {
const legacy = DEFAULT_PROJECT_SETTINGS as Record<string, unknown>;
for (const setting of BUILTIN_WORKFLOW_SETTINGS) {
// Catalog keys must exist as a known project-settings key.
expect(Object.prototype.hasOwnProperty.call(legacy, setting.id)).toBe(true);
// A declared default must byte-equal the legacy literal; an omitted
// default corresponds to a legacy `undefined` literal.
expect(setting.default).toStrictEqual(legacy[setting.id]);
}
});
});

View File

@@ -1,5 +1,6 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The built-in default workflow as a v2 IR. Its six columns have ids that are
@@ -59,6 +60,9 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],
// Workflow-settings (U1, R4): declare the full moved-key catalog with defaults
// byte-equal to today's DEFAULT_PROJECT_SETTINGS literals. Inert until U3.
settings: BUILTIN_WORKFLOW_SETTINGS,
};
export const BUILTIN_CODING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_CODING_WORKFLOW_IR);

View File

@@ -1,5 +1,6 @@
import type { WorkflowIr } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
/**
* The built-in **stepwise** coding workflow (KTD-9) — the demonstration of step
@@ -144,6 +145,8 @@ const RAW_BUILTIN_STEPWISE_CODING_WORKFLOW_IR: WorkflowIr = {
{ from: "merge", to: "end", condition: "success" },
{ from: "merge", to: "end", condition: "failure" },
],
// Workflow-settings (U1, R4): same moved-key catalog as the default builtin.
settings: BUILTIN_WORKFLOW_SETTINGS,
};
export const BUILTIN_STEPWISE_CODING_WORKFLOW_IR = parseWorkflowIr(

View File

@@ -0,0 +1,266 @@
import type { WorkflowSettingDefinition } from "./workflow-ir-types.js";
/**
* The moved-key catalog declared as workflow settings (U1, R4).
*
* Single source of truth, imported by both built-in workflow IR files
* (`builtin-coding-workflow-ir.ts`, `builtin-stepwise-coding-workflow-ir.ts`) so
* the catalog has exactly one definition.
*
* Each `default` here MUST be byte-equal to the corresponding literal in
* `DEFAULT_PROJECT_SETTINGS` (`settings-schema.ts`) — this is the parity anchor
* for the U4 hard-move migration. The U1 test
* (`workflow-ir-settings.test.ts`) asserts strict equality against the legacy
* literals. Keys with `undefined` legacy defaults (the per-phase model lanes)
* omit `default` entirely, which round-trips to the same effective value.
*
* NOTE: these declarations are inert in U1 — nothing reads them until the
* effective-settings resolver and engine integration land (U3). Adding them does
* not change any built-in workflow's behavior.
*
* Keys deliberately NOT in this catalog (per KTD-4 / the catalog-shrink rule):
* - `completionDocumentationMode` — read outside per-task scope (triage), stays
* in project settings.
* - merge-cluster keys + `maxConcurrent` — owned by the columns/traits track.
*/
export const BUILTIN_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
// ── Step execution ─────────────────────────────────────────────────────
{
id: "workflowStepTimeoutMs",
name: "Step timeout (ms)",
type: "number",
default: 360_000,
description: "Maximum time a single workflow step may run before it is timed out.",
},
{
id: "workflowStepScopeEnforcement",
name: "Step scope enforcement",
type: "enum",
default: "block",
options: [
{ value: "block", label: "Block" },
{ value: "warn", label: "Warn" },
{ value: "off", label: "Off" },
],
description: "How to handle a step that writes outside its declared file scope.",
},
{
id: "planOnlyScopeLeakEnforcement",
name: "Plan-only scope leak enforcement",
type: "enum",
default: "warn",
options: [
{ value: "off", label: "Off" },
{ value: "warn", label: "Warn" },
{ value: "block", label: "Block" },
],
description: "How to handle code changes during a plan-only step.",
},
{
id: "workflowRevisionForkOnScopeMismatch",
name: "Fork workflow revision on scope mismatch",
type: "boolean",
default: true,
description: "Fork a new workflow revision when a step's actual scope diverges from its plan.",
},
{
id: "strictScopeEnforcement",
name: "Strict scope enforcement",
type: "boolean",
default: false,
description: "Enforce declared step scope strictly, rejecting any out-of-scope change.",
},
{
id: "runStepsInNewSessions",
name: "Run steps in new sessions",
type: "boolean",
default: false,
description: "Run each workflow step in its own agent session instead of a shared one.",
},
{
id: "maxParallelSteps",
name: "Max parallel steps",
type: "number",
default: 2,
description: "Maximum number of steps to run in parallel when running steps in new sessions.",
},
{
id: "buildRetryCount",
name: "Build retry count",
type: "number",
default: 0,
description: "Number of times to retry a failing build before giving up.",
},
{
id: "buildTimeoutMs",
name: "Build timeout (ms)",
type: "number",
default: 300_000,
description: "Maximum time a build command may run before it is timed out.",
},
{
id: "verificationFixRetries",
name: "Verification fix retries",
type: "number",
default: 3,
description: "Number of automatic fix attempts after a failed verification.",
},
{
id: "maxPostReviewFixes",
name: "Max post-review fixes",
type: "number",
default: 1,
description: "Maximum number of automatic fix passes after review feedback.",
},
// ── Review / approval ──────────────────────────────────────────────────
{
id: "requirePrApproval",
name: "Require PR approval",
type: "boolean",
default: false,
description: "Require explicit approval before a pull request can be merged.",
},
{
id: "requirePlanApproval",
name: "Require plan approval",
type: "boolean",
default: false,
description: "Require explicit approval of the plan before execution begins.",
},
{
id: "reviewHandoffPolicy",
name: "Review handoff policy",
type: "enum",
default: "disabled",
options: [
{ value: "disabled", label: "Disabled" },
{ value: "comment-triggered", label: "Comment-triggered" },
{ value: "always", label: "Always" },
],
description: "When to hand off a task to a human reviewer.",
},
{
id: "maxReviewerContextRetries",
name: "Max reviewer context retries",
type: "number",
default: 2,
description: "Maximum reviewer retries due to insufficient context before falling back.",
},
{
id: "maxReviewerFallbackRetries",
name: "Max reviewer fallback retries",
type: "number",
default: 2,
description: "Maximum reviewer retries on the fallback model before failing.",
},
{
id: "reflectionEnabled",
name: "Reflection enabled",
type: "boolean",
default: false,
description: "Enable periodic reflection passes over completed work.",
},
{
id: "reflectionIntervalMs",
name: "Reflection interval (ms)",
type: "number",
default: 3_600_000,
description: "How often to run a reflection pass when reflection is enabled.",
},
{
id: "reflectionAfterTask",
name: "Reflect after each task",
type: "boolean",
default: true,
description: "Run a reflection pass after each task completes.",
},
// ── Per-phase model lanes ──────────────────────────────────────────────
// Legacy defaults are all `undefined`; `default` is omitted so resolution
// falls through to the global lane / project default (KTD-7).
{
id: "executionProvider",
name: "Execution provider",
type: "string",
description: "Provider for the execution phase. Empty falls through to the global lane.",
},
{
id: "executionModelId",
name: "Execution model",
type: "string",
description: "Model id for the execution phase. Empty falls through to the global lane.",
},
{
id: "planningProvider",
name: "Planning provider",
type: "string",
description: "Provider for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningModelId",
name: "Planning model",
type: "string",
description: "Model id for the planning phase. Empty falls through to the global lane.",
},
{
id: "planningFallbackProvider",
name: "Planning fallback provider",
type: "string",
description: "Fallback provider for the planning phase.",
},
{
id: "planningFallbackModelId",
name: "Planning fallback model",
type: "string",
description: "Fallback model id for the planning phase.",
},
{
id: "validatorProvider",
name: "Validator provider",
type: "string",
description: "Provider for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorModelId",
name: "Validator model",
type: "string",
description: "Model id for the validation phase. Empty falls through to the global lane.",
},
{
id: "validatorFallbackProvider",
name: "Validator fallback provider",
type: "string",
description: "Fallback provider for the validation phase.",
},
{
id: "validatorFallbackModelId",
name: "Validator fallback model",
type: "string",
description: "Fallback model id for the validation phase.",
},
{
id: "titleSummarizerProvider",
name: "Title summarizer provider",
type: "string",
description: "Provider for summarizing task titles.",
},
{
id: "titleSummarizerModelId",
name: "Title summarizer model",
type: "string",
description: "Model id for summarizing task titles.",
},
{
id: "titleSummarizerFallbackProvider",
name: "Title summarizer fallback provider",
type: "string",
description: "Fallback provider for summarizing task titles.",
},
{
id: "titleSummarizerFallbackModelId",
name: "Title summarizer fallback model",
type: "string",
description: "Fallback model id for summarizing task titles.",
},
];

View File

@@ -50,6 +50,8 @@ export {
serializeWorkflowIr,
WorkflowIrError,
DEFAULT_WORKFLOW_COLUMN_IDS,
WORKFLOW_SETTING_TYPES,
SETTING_RENDER_WIDGETS,
} from "./workflow-ir.js";
export type {
WorkflowIr,
@@ -70,9 +72,15 @@ export type {
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
// Workflow-settings (U1): typed setting declaration IR types.
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRender,
} from "./workflow-ir-types.js";
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
export { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "./builtin-stepwise-coding-workflow-ir.js";
export { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
// ── Trait model (U2) ─────────────────────────────────────────────────
export type {

View File

@@ -97,6 +97,48 @@ export interface WorkflowFieldDefinition {
render?: WorkflowFieldRender;
}
/** Workflow-settings (U1): the supported setting value types. A whitelist
* mirroring the scalar/enum subset of `WorkflowFieldType` — settings carry
* workflow-scoped policy (step timeouts, review gates, model lanes), so the
* date/url field types do not apply. */
export type WorkflowSettingType =
| "string"
| "text"
| "number"
| "boolean"
| "enum"
| "multi-enum";
/** A single enum/multi-enum option for a workflow setting (mirrors
* `WorkflowFieldOption`). */
export interface WorkflowSettingOption {
value: string;
label: string;
color?: string;
}
/** Rendering instructions for a workflow setting (U1, KTD-1). Settings get their
* OWN render-hint type: a widget only — NO `card`/`detail` placement, which is
* task-card-specific. The widget whitelist mirrors the field render widgets. */
export interface WorkflowSettingRender {
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
}
/** Workflow-settings (U1, R1, KTD-1): a workflow-declared typed setting. Clones
* the shape of `WorkflowFieldDefinition` (one level up) — declarations describe
* the schema; the per-`(workflowId, projectId)` value table (U2) carries data.
* `default` is consumed by the engine's effective-settings resolver (U3), so it
* is validated against its own type/options at parse time. */
export interface WorkflowSettingDefinition {
id: string;
name: string;
type: WorkflowSettingType;
default?: unknown;
options?: WorkflowSettingOption[];
description?: string;
render?: WorkflowSettingRender;
}
/** A single trait configuration applied to a column. The `trait` is an opaque
* registry id (resolved by the trait registry shipped in U2); `config` carries
* trait-specific options validated by that trait's schema. */
@@ -145,6 +187,9 @@ export interface WorkflowIrV2 {
edges: WorkflowIrEdge[];
artifacts?: WorkflowIrArtifact[];
fields?: WorkflowFieldDefinition[];
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
settings?: WorkflowSettingDefinition[];
}
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */

View File

@@ -10,6 +10,8 @@ import type {
WorkflowForeachConfig,
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowSettingDefinition,
WorkflowSettingType,
} from "./workflow-ir-types.js";
export class WorkflowIrError extends Error {
@@ -64,6 +66,27 @@ const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"toggle",
]);
/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */
export const WORKFLOW_SETTING_TYPES: ReadonlySet<WorkflowSettingType> = new Set([
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
]);
/** Workflow-settings render-widget whitelist (mirrors FIELD_RENDER_WIDGETS;
* no placement — settings have no card/detail placement). */
export const SETTING_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"select",
"radio",
"chips",
"input",
"textarea",
"toggle",
]);
/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10,
* reject <1). */
const MAX_REWORK_CYCLES_CAP = 10;
@@ -726,6 +749,139 @@ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void {
}
}
/** Validate that a setting's `default` conforms to its own type/options (U1).
* Unlike `validateFields`, settings validate defaults because the engine's
* effective-settings resolver (U3) consumes the default directly — a malformed
* default would feed garbage into execution. */
function validateSettingDefault(setting: WorkflowSettingDefinition): void {
const value = setting.default;
if (value === undefined) return;
const id = setting.id;
switch (setting.type) {
case "string":
case "text":
if (typeof value !== "string") {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be a string for type '${setting.type}'`,
);
}
break;
case "number":
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be a finite number`,
);
}
break;
case "boolean":
if (typeof value !== "boolean") {
throw new WorkflowIrError(`Workflow setting '${id}' default must be a boolean`);
}
break;
case "enum": {
const allowed = new Set((setting.options ?? []).map((o) => o.value));
if (typeof value !== "string" || !allowed.has(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default '${String(value)}' is not one of its enum options`,
);
}
break;
}
case "multi-enum": {
const allowed = new Set((setting.options ?? []).map((o) => o.value));
if (!Array.isArray(value)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default must be an array for type 'multi-enum'`,
);
}
for (const entry of value) {
if (typeof entry !== "string" || !allowed.has(entry)) {
throw new WorkflowIrError(
`Workflow setting '${id}' default '${String(entry)}' is not one of its enum options`,
);
}
}
break;
}
}
}
/** Validate `settings` declarations (U1, R1). Mirrors `validateFields`: non-empty
* unique ids, type whitelist, options iff enum-kind, unique option values, render
* widget whitelist — plus default validation (settings need it; see
* `validateSettingDefault`). */
function validateSettings(settings: WorkflowSettingDefinition[] | undefined): void {
if (settings === undefined) return;
if (!Array.isArray(settings)) {
throw new WorkflowIrError("Workflow IR settings must be an array");
}
const seen = new Set<string>();
for (const setting of settings) {
if (!setting || typeof setting.id !== "string" || setting.id === "") {
throw new WorkflowIrError("Workflow setting must have a non-empty id");
}
if (seen.has(setting.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate setting id '${setting.id}'`);
}
seen.add(setting.id);
if (typeof setting.name !== "string" || setting.name === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' must have a non-empty name`,
);
}
if (!WORKFLOW_SETTING_TYPES.has(setting.type)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' has unknown type '${String(setting.type)}'`,
);
}
const isEnum = setting.type === "enum" || setting.type === "multi-enum";
if (isEnum) {
if (!Array.isArray(setting.options) || setting.options.length === 0) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' of type '${setting.type}' must declare non-empty options`,
);
}
const optSeen = new Set<string>();
for (const opt of setting.options) {
if (!opt || typeof opt.value !== "string" || opt.value === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' option must have a non-empty value`,
);
}
if (typeof opt.label !== "string" || opt.label === "") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' option '${opt.value}' must have a non-empty label`,
);
}
if (optSeen.has(opt.value)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' has duplicate option value '${opt.value}'`,
);
}
optSeen.add(opt.value);
}
} else if (setting.options !== undefined) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' of type '${setting.type}' must not declare options`,
);
}
if (setting.description !== undefined && typeof setting.description !== "string") {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' description must be a string`,
);
}
if (setting.render !== undefined) {
const r = setting.render;
if (r.widget !== undefined && !SETTING_RENDER_WIDGETS.has(r.widget)) {
throw new WorkflowIrError(
`Workflow setting '${setting.id}' render.widget '${String(r.widget)}' is not allowed`,
);
}
}
validateSettingDefault(setting);
}
}
function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(ir.columns)) {
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
@@ -781,6 +937,7 @@ function validateV2(ir: WorkflowIrV2): void {
validateParseStepsNodes(ir);
validateCodeNodes(ir.nodes);
validateFields(ir.fields);
validateSettings(ir.settings);
// Rework edges are legal only intra-template; any rework edge at the top level
// is rejected (template rework edges are validated inside validateForeach and
@@ -869,8 +1026,13 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (!V1_NODE_KINDS.has(node.kind)) return ir;
}
// Step-inversion declarations (artifacts/fields) are v2-only features.
if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) {
// Step-inversion declarations (artifacts/fields) and workflow settings (U1)
// are v2-only features.
if (
(ir.artifacts && ir.artifacts.length > 0) ||
(ir.fields && ir.fields.length > 0) ||
(ir.settings && ir.settings.length > 0)
) {
return ir;
}