feat(core): workflow IR typed settings declarations with built-in moved-key catalog
This commit is contained in:
229
packages/core/src/__tests__/workflow-ir-settings.test.ts
Normal file
229
packages/core/src/__tests__/workflow-ir-settings.test.ts
Normal 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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
266
packages/core/src/builtin-workflow-settings.ts
Normal file
266
packages/core/src/builtin-workflow-settings.ts
Normal 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.",
|
||||
},
|
||||
];
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user