diff --git a/packages/core/src/__tests__/workflow-ir-settings.test.ts b/packages/core/src/__tests__/workflow-ir-settings.test.ts new file mode 100644 index 0000000000..a3ce1c3692 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-settings.test.ts @@ -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; + 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]); + } + }); +}); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 381d78156e..3ae675ed8d 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -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); diff --git a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts index 150e47f96e..179ae134f5 100644 --- a/packages/core/src/builtin-stepwise-coding-workflow-ir.ts +++ b/packages/core/src/builtin-stepwise-coding-workflow-ir.ts @@ -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( diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts new file mode 100644 index 0000000000..7f3cce9676 --- /dev/null +++ b/packages/core/src/builtin-workflow-settings.ts @@ -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.", + }, +]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..4d99fe1da2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index b96e538fb5..91b78e0743 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -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). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 6a3cc5eff8..265f22c3e7 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -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 = new Set([ "toggle", ]); +/** Workflow-settings (U1) value-type whitelist (mirrors WORKFLOW_FIELD_TYPES). */ +export const WORKFLOW_SETTING_TYPES: ReadonlySet = 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 = 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(); + 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(); + 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; }