feat(cli): add lint command (#7917)

* feat(yaml-validator)!: unify flow, schedule, and trigger validation

- replace FlowValidator with WindmillYamlValidator.validate(doc, target)

- generate schedule/trigger schemas from backend OpenAPI and OpenFlow refs

- add schedule/trigger/filename-target tests and update AI agent fixtures

- bump windmill-yaml-validator to 2.0.0

BREAKING CHANGE: FlowValidator and validateFlow() are replaced by WindmillYamlValidator.validate(doc, target).

* add lint command

* add deno-compat script and docs for local yaml-validator testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make nullable fields pass yaml validation

Add nullable: true to static_asset_config and authentication_resource_path
in HttpTrigger schema. Post-process generated JSON schemas to add null to
enums with nullable: true (AJV doesn't handle OpenAPI 3.0 nullable + enum).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add nullable to all Option<T> fields in trigger and schedule OpenAPI schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): handle nullable fields from updated OpenAPI types

Add ?? undefined coalescing at assignment sites where generated types
now include | null from the OpenAPI nullable additions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(lint): show allowed values in enum validation errors

Instead of "must be equal to one of the allowed values", now shows
"must be one of: 'r', 'w', 'rw'" for enum validation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add nullable to Edit/New trigger and schedule OpenAPI schemas

Ensures create/update request body types accept null for the same
fields that GET response types return as nullable, enabling clean
round-tripping without type mismatches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* use published package

* publish

* refactor(lint): remove unused --includes/--excludes/--extra-includes CLI options

These options were defined but never wired to the file filtering logic.
The lint command still respects includes/excludes from wmill.yaml via
mergeConfigWithConfigFile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(lint): handle additionalProperties errors and expand test coverage

Add formatting for AJV additionalProperties keyword to show the unknown
property name. Add unit tests for all formatValidationError branches and
integration tests for --json report shape, --fail-on-warn with mixed
files, non-existent directory, and enum error output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add realistic validator tests for schedules, triggers, and edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add email trigger validation support

Add email trigger schema generation, validation, and linting. Email
triggers are no longer skipped with a warning — they are validated
like all other trigger types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(cli): bump windmill-yaml-validator to 1.1.1 (email trigger support)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* publish

* rm

* fix: address PR review feedback for lint command

- Add email to trigger kinds test loop instead of separate test
- Add email to ValidationTarget docs in README
- Type formatYamlDiagnostics param directly instead of unsafe cast
- Destructure json option before mergeConfigWithConfigFile for clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add --lint option to sync push command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-13 17:41:04 +01:00
committed by GitHub
parent a9e4a5c8e7
commit 37d1277b91
31 changed files with 2189 additions and 130 deletions

View File

@@ -1,14 +1,19 @@
# Windmill YAML Validator
A TypeScript-based YAML validator for Windmill flow files. This package validates flow.yaml files against the OpenFlow JSON schema to ensure they conform to the Windmill flow specification.
A TypeScript-based YAML validator for Windmill flow, schedule, and trigger files.
## Overview
The windmill-yaml-validator provides runtime validation for Windmill flow YAML files. It is currently used in the **Windmill VSCode extension** to show syntax errors and validation issues on `flow.yaml` files in real-time as developers edit them.
The windmill-yaml-validator provides runtime validation for Windmill YAML files. It is used by editor integrations to show validation errors while editing:
- `flow.yaml` / `flow.yml`
- `*.schedule.yaml` / `*.schedule.yml`
- `*.{http|websocket|kafka|nats|postgres|mqtt|sqs|gcp}_trigger.yaml` (or `.yml`)
## Features
- **Schema-based validation**: Validates against the official OpenFlow JSON schema
- **Unified validation API**: One validator class for flow/schedule/trigger files
- **Schema-based validation**: Uses OpenFlow and backend OpenAPI-derived schemas
- **Detailed error reporting**: Returns comprehensive error information with specific paths to invalid fields
## Installation
@@ -22,29 +27,70 @@ npm install windmill-yaml-validator
### Basic Validation
```typescript
import { FlowValidator } from "windmill-yaml-validator";
import { WindmillYamlValidator } from "windmill-yaml-validator";
const validator = new FlowValidator();
const validator = new WindmillYamlValidator();
const yamlContent = `
const flowYaml = `
summary: Test Flow
value:
modules: []
`;
const result = validator.validateFlow(yamlContent);
const flowResult = validator.validate(flowYaml, { type: "flow" });
if (result.errors.length === 0) {
console.log("Flow is valid!");
} else {
console.log("Validation errors:", result.errors);
const scheduleYaml = `
schedule: "0 0 12 * * *"
timezone: "UTC"
enabled: true
script_path: "f/jobs/daily_sync"
is_flow: false
`;
const scheduleResult = validator.validate(scheduleYaml, { type: "schedule" });
const triggerYaml = `
script_path: "f/triggers/http_handler"
is_flow: false
route_path: "api/webhook"
request_type: "sync"
authentication_method: "none"
http_method: "post"
is_static_website: false
workspaced_route: false
wrap_body: false
raw_string: false
`;
const triggerResult = validator.validate(triggerYaml, {
type: "trigger",
triggerKind: "http",
});
console.log(flowResult.errors, scheduleResult.errors, triggerResult.errors);
```
### Target Inference by Filename
```typescript
import {
WindmillYamlValidator,
getValidationTargetFromFilename,
} from "windmill-yaml-validator";
const validator = new WindmillYamlValidator();
const target = getValidationTargetFromFilename(
"f/webhooks/order_created.http_trigger.yaml"
);
if (target) {
const result = validator.validate(fileContents, target);
console.log(result.errors);
}
```
### Error Handling
The validator returns detailed error information for invalid flows:
```typescript
const invalidYaml = `
summary: 123 # Should be a string
@@ -56,7 +102,7 @@ value:
language: invalid_language # Invalid enum value
`;
const result = validator.validateFlow(invalidYaml);
const result = validator.validate(invalidYaml, { type: "flow" });
result.errors.forEach((error) => {
console.log(`Error at ${error.instancePath}: ${error.message}`);
@@ -68,27 +114,31 @@ result.errors.forEach((error) => {
## API
### `FlowValidator`
### `WindmillYamlValidator`
Main validator class that validates Windmill flow YAML files.
Main validator class for Windmill YAML validation.
#### Constructor
```typescript
new FlowValidator();
new WindmillYamlValidator();
```
Creates a new validator instance. The constructor initializes the AJV validator with the OpenFlow schema.
Initializes AJV validators for flow, schedule, and trigger schemas.
#### Methods
##### `validateFlow(doc: string)`
##### `validate(doc: string, target: ValidationTarget)`
Validates a flow document against the OpenFlow schema.
Validates a YAML document against the selected target schema.
**Parameters:**
- `doc` (string): The YAML flow document as a string
- `doc` (string): YAML document string
- `target` (`ValidationTarget`):
- `{ type: "flow" }`
- `{ type: "schedule" }`
- `{ type: "trigger", triggerKind: "http" | "websocket" | "kafka" | "nats" | "postgres" | "mqtt" | "sqs" | "gcp" | "email" }`
**Returns:**
@@ -103,6 +153,10 @@ Validates a flow document against the OpenFlow schema.
- Error if `doc` is not a string
### `getValidationTargetFromFilename(path: string)`
Infers validation target from file naming conventions. Returns `null` for unsupported files.
## Development
### Building
@@ -113,7 +167,10 @@ npm run build
The build process:
1. Runs `gen_openflow_schema.sh` to generate the OpenFlow JSON schema from `openflow.openapi.yaml`
1. Runs `gen_openflow_schema.sh` to generate:
- `src/gen/openflow.json`
- `src/gen/schedule.json`
- `src/gen/triggers/*.json`
2. Removes discriminator mappings (not supported by AJV)
3. Compiles TypeScript to JavaScript
@@ -129,6 +186,44 @@ Run tests in watch mode:
npm test:watch
```
### Testing locally with the CLI
The Windmill CLI (`cli/`) is Deno-based and imports this package via `npm:windmill-yaml-validator@1.1.0`. Since Deno's `npm:` specifier always resolves from the npm registry, local testing requires a compatibility script that makes the TypeScript sources directly importable by Deno.
The `deno-compat.sh` script handles two Deno requirements:
- Adding `.ts` extensions to relative imports
- Adding `with { type: "json" }` assertions to JSON imports
**Steps:**
1. Apply Deno compatibility:
```bash
./deno-compat.sh
```
2. Add the following entries to `cli/deno.json` imports:
```json
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
"ajv": "npm:ajv@^8.17.1",
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
```
3. Run the CLI directly with Deno:
```bash
cd ../cli
deno run -A src/main.ts lint
```
4. When done, restore everything:
```bash
./deno-compat.sh -r # restore original imports
# Remove the 3 import map lines from cli/deno.json
```
### Schema Generation
The validator uses a JSON schema generated from the OpenAPI specification:
@@ -139,10 +234,10 @@ The validator uses a JSON schema generated from the OpenAPI specification:
This script:
- Bundles `openflow.openapi.yaml` into a single JSON schema
- Converts `openflow.openapi.yaml` and `backend/windmill-api/openapi.yaml` into JSON
- Removes discriminator mappings for AJV compatibility
- Removes the `ToolValue` discriminator entirely (see below)
- Outputs to `src/gen/openflow.json`
- Generates standalone schedule/trigger schemas for CLI file shape
#### Why Remove Discriminators?
@@ -157,3 +252,7 @@ The OpenFlow schema uses OpenAPI discriminators for efficient type resolution in
- Is slightly slower but still performant for our use case
- Provides the same validation correctness
- Works correctly with complex schema compositions like `allOf`
## Breaking Change
`FlowValidator` and `validateFlow()` were replaced by `WindmillYamlValidator` and `validate(doc, target)`.

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Makes windmill-yaml-validator source files Deno-compatible by:
# 1. Adding .ts extensions to relative imports
# 2. Adding `with { type: "json" }` to JSON imports
# Use -r to restore (undo changes).
set -e
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESTORE_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
-r)
RESTORE_MODE=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [-r]"
echo " -r Restore original imports"
exit 1
;;
esac
done
if [[ "$OSTYPE" == "darwin"* ]]; then
SED=gsed
if ! command -v gsed &> /dev/null; then
echo "Error: gsed not found. Run: brew install gnu-sed"
exit 1
fi
else
SED=sed
fi
if [[ "$RESTORE_MODE" == true ]]; then
echo "Restoring original imports..."
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
# Remove .ts from relative imports: from "./foo.ts" -> from "./foo"
$SED -E -i 's|(from "\.\.?/[^"]*)\.ts(")|\1\2|g' "$file"
# Remove ` with { type: "json" }` from JSON imports
$SED -E -i 's/ with \{ type: "json" \}//' "$file"
done
echo "✓ Restored original imports"
else
echo "Making sources Deno-compatible..."
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
# Add .ts to relative imports that don't already end in .ts or .json
$SED -E -i '/\.json"/! { /\.ts"/! s|(from "(\.\.?/[^"]*[^/]))"(;?)$|\1.ts"\3|; }' "$file"
# Add `with { type: "json" }` to .json imports that don't already have it
$SED -E -i '/with \{ type: "json" \}/! s/(from "[^"]*\.json")(;?)$/\1 with { type: "json" }\2/' "$file"
done
echo "✓ Sources are now Deno-compatible"
fi

View File

@@ -1,10 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
output_dirpath="${script_dirpath}/src/gen"
tmp_dirpath="$(mktemp -d)"
cleanup() {
rm -rf "${tmp_dirpath}"
}
trap cleanup EXIT
mkdir -p "${output_dirpath}"
npx @redocly/openapi-cli@latest bundle "${script_dirpath}/../openflow.openapi.yaml" --ext json > "${output_dirpath}/openflow.json"
mkdir -p "${output_dirpath}/triggers"
node -e "
const fs = require('fs');
const yaml = require('js-yaml');
const openflowPath = '${script_dirpath}/../openflow.openapi.yaml';
const backendPath = '${script_dirpath}/../backend/windmill-api/openapi.yaml';
const openflowOutputPath = '${output_dirpath}/openflow.json';
const backendOutputPath = '${tmp_dirpath}/backend-openapi.json';
const openflowData = yaml.load(fs.readFileSync(openflowPath, 'utf8'));
const backendData = yaml.load(fs.readFileSync(backendPath, 'utf8'));
fs.writeFileSync(openflowOutputPath, JSON.stringify(openflowData, null, 2) + '\\n');
fs.writeFileSync(backendOutputPath, JSON.stringify(backendData, null, 2) + '\\n');
"
# Remove discriminator mapping from openflow.json as it's not supported by ajv
node -e "
@@ -34,4 +58,39 @@ try {
} catch (e) {
console.error('Error removing discriminator mappings:', e);
}
"
"
node "${script_dirpath}/scripts/generate-resource-schemas.js" \
"${tmp_dirpath}/backend-openapi.json" \
"${output_dirpath}/openflow.json" \
"${output_dirpath}"
# AJV does not handle OpenAPI 3.0 `nullable: true` combined with `enum` — null must
# be explicitly listed in the enum for validation to accept null values.
# We post-process all generated JSON schemas to add null to such enums.
node -e "
const fs = require('fs');
const path = require('path');
function addNullToNullableEnums(obj) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { obj.forEach(addNullToNullableEnums); return; }
if (obj.nullable === true && Array.isArray(obj.enum) && !obj.enum.includes(null)) {
obj.enum.push(null);
}
for (const v of Object.values(obj)) addNullToNullableEnums(v);
}
const files = [
'${output_dirpath}/openflow.json',
'${output_dirpath}/schedule.json',
...fs.readdirSync('${output_dirpath}/triggers').map(f => path.join('${output_dirpath}/triggers', f))
].filter(f => f.endsWith('.json'));
for (const file of files) {
const schema = JSON.parse(fs.readFileSync(file, 'utf8'));
addNullToNullableEnums(schema);
fs.writeFileSync(file, JSON.stringify(schema, null, 2) + '\n');
}
console.log('Added null to nullable enums in generated schemas');
"

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.0.4",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.0.4",
"version": "1.1.1",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
@@ -16,6 +16,7 @@
"@types/jest": "^29.5.0",
"@types/node": "^24.1.0",
"jest": "^29.5.0",
"js-yaml": "^3.14.1",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
}

View File

@@ -1,11 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.0.4",
"description": "YAML validator for Windmill",
"version": "1.1.1",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "./gen_openflow_schema.sh && tsc",
"pretest": "./gen_openflow_schema.sh",
"test": "jest",
"test:watch": "jest --watch",
"prepublishOnly": "npm run build"
@@ -18,6 +19,7 @@
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^24.1.0",
"js-yaml": "^3.14.1",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.0"
@@ -29,4 +31,4 @@
"@stoplight/yaml": "^4.3.0",
"ajv": "^8.17.1"
}
}
}

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const CLI_EXCLUDED_FIELDS = new Set([
"workspace_id",
"path",
"name",
"versions",
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"edited_at",
"edited_by",
"archived",
"has_draft",
"error",
"last_server_ping",
"server_id",
"extra_perms",
"email",
"mode",
]);
const TARGET_SCHEMAS = {
schedule: "Schedule",
triggers: {
http: "HttpTrigger",
websocket: "WebsocketTrigger",
kafka: "KafkaTrigger",
nats: "NatsTrigger",
postgres: "PostgresTrigger",
mqtt: "MqttTrigger",
sqs: "SqsTrigger",
gcp: "GcpTrigger",
email: "EmailTrigger",
},
};
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function mergeSchemas(target, source) {
if (!source || typeof source !== "object") {
return;
}
if (source.type && !target.type) {
target.type = source.type;
}
if (source.properties && typeof source.properties === "object") {
target.properties = target.properties || {};
Object.assign(target.properties, deepClone(source.properties));
}
if (Array.isArray(source.required)) {
target.required = target.required || [];
target.required.push(...source.required);
}
}
function getRefName(refPath) {
const marker = "#/components/schemas/";
const markerIndex = refPath.indexOf(marker);
if (markerIndex === -1) {
return null;
}
return refPath.slice(markerIndex + marker.length);
}
function resolveRef(refPath, backendSchemas, openflowSchemas) {
const refName = getRefName(refPath);
if (!refName) {
return null;
}
if (refPath.startsWith("#/components/schemas/")) {
if (backendSchemas[refName]) {
return deepClone(backendSchemas[refName]);
}
if (openflowSchemas[refName]) {
return deepClone(openflowSchemas[refName]);
}
return null;
}
if (refPath.includes("openflow.openapi.yaml#/components/schemas/")) {
if (openflowSchemas[refName]) {
return deepClone(openflowSchemas[refName]);
}
return null;
}
return null;
}
function extractCliSchema(schema, allSchemas, openflowSchemas) {
if (!schema || typeof schema !== "object") {
return {};
}
const result = { type: "object", properties: {}, required: [] };
if (Array.isArray(schema.allOf)) {
for (const item of schema.allOf) {
if (!item || typeof item !== "object") {
continue;
}
if (item.$ref) {
const refSchema = resolveRef(item.$ref, allSchemas, openflowSchemas);
const transformed = extractCliSchema(refSchema, allSchemas, openflowSchemas);
mergeSchemas(result, transformed);
} else {
const transformed = extractCliSchema(item, allSchemas, openflowSchemas);
mergeSchemas(result, transformed);
}
}
}
if (schema.properties && typeof schema.properties === "object") {
for (const [key, value] of Object.entries(schema.properties)) {
if (CLI_EXCLUDED_FIELDS.has(key)) {
continue;
}
result.properties[key] = deepClone(value);
}
}
if (Array.isArray(schema.required)) {
for (const field of schema.required) {
if (!CLI_EXCLUDED_FIELDS.has(field)) {
result.required.push(field);
}
}
}
const dedupRequired = Array.from(new Set(result.required));
result.required = dedupRequired.filter((key) => key in result.properties);
return result;
}
function resolveSchemaRefs(value, backendSchemas, openflowSchemas, stack = new Set()) {
if (Array.isArray(value)) {
return value.map((item) => resolveSchemaRefs(item, backendSchemas, openflowSchemas, stack));
}
if (!value || typeof value !== "object") {
return value;
}
if (typeof value.$ref === "string") {
const refName = getRefName(value.$ref);
if (!refName) {
return value;
}
if (stack.has(refName)) {
return {};
}
const resolved = resolveRef(value.$ref, backendSchemas, openflowSchemas);
if (!resolved) {
return value;
}
const merged = { ...resolved, ...value };
delete merged.$ref;
const nextStack = new Set(stack);
nextStack.add(refName);
return resolveSchemaRefs(merged, backendSchemas, openflowSchemas, nextStack);
}
const result = {};
for (const [key, nested] of Object.entries(value)) {
result[key] = resolveSchemaRefs(nested, backendSchemas, openflowSchemas, stack);
}
return result;
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
}
function generateSchemas(backendPath, openflowPath, outputDir) {
const backendOpenapi = loadJson(backendPath);
const openflowOpenapi = loadJson(openflowPath);
const backendSchemas = backendOpenapi.components?.schemas || {};
const openflowSchemas = openflowOpenapi.components?.schemas || {};
const scheduleSchema = extractCliSchema(
backendSchemas[TARGET_SCHEMAS.schedule],
backendSchemas,
openflowSchemas
);
const resolvedScheduleSchema = resolveSchemaRefs(scheduleSchema, backendSchemas, openflowSchemas);
writeJson(path.join(outputDir, "schedule.json"), resolvedScheduleSchema);
for (const [triggerKind, schemaName] of Object.entries(TARGET_SCHEMAS.triggers)) {
const triggerSchema = extractCliSchema(
backendSchemas[schemaName],
backendSchemas,
openflowSchemas
);
const resolvedTriggerSchema = resolveSchemaRefs(triggerSchema, backendSchemas, openflowSchemas);
writeJson(path.join(outputDir, "triggers", `${triggerKind}.json`), resolvedTriggerSchema);
}
}
function main() {
const [backendPath, openflowPath, outputDir] = process.argv.slice(2);
if (!backendPath || !openflowPath || !outputDir) {
console.error(
"Usage: node scripts/generate-resource-schemas.js <backend-openapi.json> <openflow.json> <output-dir>"
);
process.exit(1);
}
generateSchemas(backendPath, openflowPath, outputDir);
console.log("Generated schedule and trigger schemas");
}
main();

View File

@@ -1 +1 @@
export * from "./validation";
export * from "./validation/index";

View File

@@ -1,9 +1,9 @@
import { FlowValidator } from "../flow-validator";
import { WindmillYamlValidator } from "../yaml-validator";
import * as fs from "fs";
import * as path from "path";
describe("FlowValidator", () => {
let validator: FlowValidator;
describe("WindmillYamlValidator", () => {
let validator: WindmillYamlValidator;
const samplesDir = path.join(__dirname, "test-samples");
const readSample = (filename: string): string => {
@@ -11,31 +11,31 @@ describe("FlowValidator", () => {
};
beforeEach(() => {
validator = new FlowValidator();
validator = new WindmillYamlValidator();
});
describe("constructor", () => {
it("should create a validator instance", () => {
expect(validator).toBeInstanceOf(FlowValidator);
expect(validator).toBeInstanceOf(WindmillYamlValidator);
});
it("should initialize without throwing", () => {
expect(() => new FlowValidator()).not.toThrow();
expect(() => new WindmillYamlValidator()).not.toThrow();
});
});
describe("validateFlow", () => {
describe("validate (flow target)", () => {
it("should throw error for non-string input", () => {
expect(() => validator.validateFlow(null as any)).toThrow(
expect(() => validator.validate(null as any, { type: "flow" })).toThrow(
"Document must be a string"
);
expect(() => validator.validateFlow(123 as any)).toThrow(
expect(() => validator.validate(123 as any, { type: "flow" })).toThrow(
"Document must be a string"
);
expect(() => validator.validateFlow({} as any)).toThrow(
expect(() => validator.validate({} as any, { type: "flow" })).toThrow(
"Document must be a string"
);
expect(() => validator.validateFlow([] as any)).toThrow(
expect(() => validator.validate([] as any, { type: "flow" })).toThrow(
"Document must be a string"
);
});
@@ -44,7 +44,7 @@ describe("FlowValidator", () => {
it("should validate a valid minimal flow from sample file", () => {
const validFlow = readSample("valid-minimal.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
expect(result.parsed).toBeDefined();
@@ -59,7 +59,7 @@ describe("FlowValidator", () => {
it("should validate a script flow from sample file", () => {
const validFlow = readSample("valid-script-flow.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
expect(result.parsed.data).toMatchObject({
@@ -94,7 +94,7 @@ describe("FlowValidator", () => {
it("should return errors for missing summary from sample file", () => {
const invalidFlow = readSample("invalid-missing-summary.yaml");
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
expect(
@@ -110,7 +110,7 @@ describe("FlowValidator", () => {
it("should return errors for invalid types from sample file", () => {
const invalidFlow = readSample("invalid-wrong-types.yaml");
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
expect(
@@ -124,7 +124,7 @@ describe("FlowValidator", () => {
it("should return errors for invalid language from sample file", () => {
const invalidFlow = readSample("invalid-language.yaml");
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
expect(
@@ -139,7 +139,7 @@ describe("FlowValidator", () => {
it("should handle empty file from sample", () => {
const emptyFlow = readSample("empty.yaml");
const result = validator.validateFlow(emptyFlow);
const result = validator.validate(emptyFlow, { type: "flow" });
expect(result.parsed).toBeDefined();
expect(result.errors.length).toBeGreaterThan(0);
@@ -148,7 +148,7 @@ describe("FlowValidator", () => {
it("should handle complex invalid flow with comprehensive error detection", () => {
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
const result = validator.validateFlow(complexInvalidFlow);
const result = validator.validate(complexInvalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(20); // Should have many errors
@@ -202,7 +202,7 @@ describe("FlowValidator", () => {
it("should handle deeply nested invalid structures with detailed error reporting", () => {
const nestedInvalidFlow = readSample("invalid-nested-structures.yaml");
const result = validator.validateFlow(nestedInvalidFlow);
const result = validator.validate(nestedInvalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(10); // Should have many nested errors
@@ -244,7 +244,7 @@ describe("FlowValidator", () => {
it("should provide specific error locations for complex validation failures", () => {
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
const result = validator.validateFlow(complexInvalidFlow);
const result = validator.validate(complexInvalidFlow, { type: "flow" });
// Verify that errors have meaningful instance paths
const errorsWithPaths = result.errors.filter(
@@ -268,7 +268,7 @@ describe("FlowValidator", () => {
it("should handle all major flow control structures with validation errors", () => {
const complexInvalidFlow = readSample("invalid-complex-flow.yaml");
const result = validator.validateFlow(complexInvalidFlow);
const result = validator.validate(complexInvalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(20);
@@ -317,7 +317,7 @@ describe("FlowValidator", () => {
it("should validate a basic AI agent flow with FlowModule tools", () => {
const validFlow = readSample("valid-aiagent-basic.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
});
@@ -325,7 +325,7 @@ describe("FlowValidator", () => {
it("should validate an AI agent flow with MCP tools", () => {
const validFlow = readSample("valid-aiagent-mcp.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
});
@@ -333,7 +333,7 @@ describe("FlowValidator", () => {
it("should validate an AI agent flow with mixed FlowModule and MCP tools", () => {
const validFlow = readSample("valid-aiagent-mixed.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
});
@@ -341,7 +341,7 @@ describe("FlowValidator", () => {
it("should validate an AI agent flow with parallel execution enabled", () => {
const validFlow = readSample("valid-aiagent-parallel.yaml");
const result = validator.validateFlow(validFlow);
const result = validator.validate(validFlow, { type: "flow" });
expect(result.errors).toHaveLength(0);
// Verify tools array has expected structure
@@ -354,7 +354,7 @@ describe("FlowValidator", () => {
it("should return errors for AI agent missing required tools field", () => {
const invalidFlow = readSample("invalid-aiagent-missing-tools.yaml");
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
expect(
@@ -369,7 +369,7 @@ describe("FlowValidator", () => {
it("should return errors for AI agent missing type field", () => {
const invalidFlow = readSample("invalid-aiagent-missing-type.yaml");
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
// Should fail discriminator validation since type is missing
@@ -387,7 +387,7 @@ describe("FlowValidator", () => {
"invalid-aiagent-invalid-tool-type.yaml"
);
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
// Should fail discriminator validation for invalid tool_type
@@ -404,7 +404,7 @@ describe("FlowValidator", () => {
"invalid-aiagent-mcp-missing-resource.yaml"
);
const result = validator.validateFlow(invalidFlow);
const result = validator.validate(invalidFlow, { type: "flow" });
expect(result.errors.length).toBeGreaterThan(0);
expect(

View File

@@ -0,0 +1,643 @@
import {
getValidationTargetFromFilename,
TriggerKind,
WindmillYamlValidator,
} from "../yaml-validator";
describe("WindmillYamlValidator resource validation", () => {
let validator: WindmillYamlValidator;
beforeEach(() => {
validator = new WindmillYamlValidator();
});
// ── Schedules ──────────────────────────────────────────────────────────
describe("schedules", () => {
it("validates a minimal schedule", () => {
const result = validator.validate(
JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
}),
{ type: "schedule" }
);
expect(result.errors).toHaveLength(0);
});
it("validates a fully-configured schedule with handlers and retry", () => {
const result = validator.validate(
JSON.stringify({
schedule: "0 */5 * * * *",
timezone: "Europe/Paris",
enabled: true,
script_path: "f/jobs/full_sync",
is_flow: true,
args: { batch_size: 100, dry_run: false },
on_failure: "f/handlers/on_fail",
on_failure_times: 3,
on_failure_exact: true,
on_failure_extra_args: { notify: true },
on_recovery: "f/handlers/on_recover",
on_recovery_times: 2,
on_recovery_extra_args: { channel: "#ops" },
on_success: "f/handlers/on_success",
on_success_extra_args: { log: true },
ws_error_handler_muted: true,
retry: {
constant: { attempts: 3, seconds: 10 },
exponential: {
attempts: 5,
multiplier: 2,
seconds: 1,
random_factor: 50,
},
retry_if: { expr: "error.message.includes('timeout')" },
},
summary: "Full sync every 5 minutes",
description: "Runs the full synchronization pipeline",
no_flow_overlap: true,
tag: "heavy",
paused_until: "2026-03-01T00:00:00Z",
cron_version: "v2",
dynamic_skip: "f/helpers/should_skip",
}),
{ type: "schedule" }
);
expect(result.errors).toHaveLength(0);
});
it("accepts null for all nullable optional fields", () => {
const result = validator.validate(
JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
args: null,
on_failure: null,
tag: null,
retry: null,
paused_until: null,
summary: null,
description: null,
cron_version: null,
dynamic_skip: null,
}),
{ type: "schedule" }
);
expect(result.errors).toHaveLength(0);
});
it("rejects a schedule missing required fields", () => {
const result = validator.validate(
JSON.stringify({ timezone: "UTC" }),
{ type: "schedule" }
);
expect(result.errors.length).toBeGreaterThan(0);
});
it("rejects wrong types in schedule fields", () => {
const result = validator.validate(
JSON.stringify({
schedule: 12345,
timezone: "UTC",
enabled: "yes",
script_path: "f/jobs/daily_sync",
is_flow: "true",
}),
{ type: "schedule" }
);
// Should catch schedule (not string), enabled (not boolean), is_flow (not boolean)
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
it("rejects invalid retry constraints", () => {
const result = validator.validate(
JSON.stringify({
schedule: "0 0 12 * * *",
timezone: "UTC",
enabled: true,
script_path: "f/jobs/daily_sync",
is_flow: false,
retry: {
exponential: { attempts: 3, seconds: 0, random_factor: 150 },
retry_if: {},
},
}),
{ type: "schedule" }
);
// seconds < 1, random_factor > 100, retry_if missing expr
expect(result.errors.length).toBeGreaterThanOrEqual(3);
});
});
// ── Triggers — valid minimal + valid with missing required ─────────────
describe("trigger schemas", () => {
const validTriggers: Record<TriggerKind, Record<string, unknown>> = {
http: {
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync",
authentication_method: "none",
http_method: "post",
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
},
websocket: {
script_path: "f/triggers/ws_handler",
is_flow: false,
url: "wss://example.com/socket",
filters: [],
can_return_message: false,
can_return_error_result: true,
},
kafka: {
script_path: "f/triggers/kafka_handler",
is_flow: false,
kafka_resource_path: "f/resources/kafka",
group_id: "group-a",
topics: ["topic-a"],
filters: [],
},
nats: {
script_path: "f/triggers/nats_handler",
is_flow: false,
nats_resource_path: "f/resources/nats",
use_jetstream: false,
subjects: ["events.>"],
},
postgres: {
script_path: "f/triggers/postgres_handler",
is_flow: false,
postgres_resource_path: "f/resources/postgres",
publication_name: "pub_main",
replication_slot_name: "slot_main",
},
mqtt: {
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [],
},
sqs: {
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "credentials",
},
gcp: {
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "topic-a",
subscription_id: "sub-a",
delivery_type: "pull",
subscription_mode: "existing",
},
email: {
script_path: "f/triggers/email_handler",
is_flow: false,
local_part: "inbox",
},
};
const missingRequiredField: Record<TriggerKind, string> = {
http: "request_type",
websocket: "url",
kafka: "topics",
nats: "subjects",
postgres: "publication_name",
mqtt: "mqtt_resource_path",
sqs: "queue_url",
gcp: "topic_id",
email: "local_part",
};
for (const [kind, validDocument] of Object.entries(validTriggers) as [
TriggerKind,
Record<string, unknown>,
][]) {
it(`validates a valid ${kind} trigger file`, () => {
const result = validator.validate(JSON.stringify(validDocument), {
type: "trigger",
triggerKind: kind,
});
expect(result.errors).toHaveLength(0);
});
it(`returns required-field errors for invalid ${kind} trigger file`, () => {
const missingField = missingRequiredField[kind];
const invalidDocument = { ...validDocument };
delete invalidDocument[missingField];
const result = validator.validate(JSON.stringify(invalidDocument), {
type: "trigger",
triggerKind: kind,
});
expect(result.errors.length).toBeGreaterThan(0);
expect(
result.errors.some(
(error) =>
error.keyword === "required" &&
(error.params as { missingProperty?: string })?.missingProperty ===
missingField
)
).toBe(true);
});
}
});
it("throws for unsupported trigger kinds", () => {
expect(() =>
validator.validate("{}", {
type: "trigger",
triggerKind: "foobar" as any,
})
).toThrow("Unsupported trigger kind: foobar");
});
// ── Triggers — realistic full documents with all optional fields ───────
describe("fully-configured triggers", () => {
it("validates http trigger with static assets, auth, error handling and retry", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync_sse",
authentication_method: "api_key",
http_method: "put",
is_static_website: true,
workspaced_route: true,
wrap_body: true,
raw_string: false,
summary: "Incoming webhook",
description: "Handles external webhook deliveries",
authentication_resource_path: "f/resources/api_key_config",
static_asset_config: { s3: "my-bucket/assets", filename: "index.html" },
error_handler_path: "f/handlers/on_error",
error_handler_args: { notify: true },
retry: {
constant: { attempts: 2, seconds: 5 },
retry_if: { expr: "error.status === 429" },
},
}),
{ type: "trigger", triggerKind: "http" }
);
expect(result.errors).toHaveLength(0);
});
it("validates websocket trigger with initial messages and filters", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/ws_handler",
is_flow: true,
url: "wss://stream.example.com/v1",
filters: [{ key: "event", value: "trade" }],
can_return_message: true,
can_return_error_result: false,
initial_messages: [
{ raw_message: '{"action":"subscribe","channel":"trades"}' },
{
runnable_result: {
path: "f/helpers/ws_auth",
args: { token: "abc" },
is_flow: false,
},
},
],
url_runnable_args: { env: "production" },
error_handler_path: "f/handlers/on_error",
retry: { exponential: { attempts: 5, multiplier: 2, seconds: 1, random_factor: 25 } },
}),
{ type: "trigger", triggerKind: "websocket" }
);
expect(result.errors).toHaveLength(0);
});
it("validates mqtt trigger with v5 config and subscribe topics", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [
{ topic: "sensor/+/data", qos: "qos1" },
{ topic: "alerts/#", qos: "qos2" },
],
client_version: "v5",
client_id: "windmill-consumer-1",
v5_config: {
clean_start: true,
topic_alias_maximum: 10,
session_expiry_interval: 300,
},
error_handler_path: "f/handlers/on_error",
retry: { constant: { attempts: 3, seconds: 10 } },
}),
{ type: "trigger", triggerKind: "mqtt" }
);
expect(result.errors).toHaveLength(0);
});
it("validates nats trigger with jetstream config", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/nats_handler",
is_flow: true,
nats_resource_path: "f/resources/nats",
use_jetstream: true,
subjects: ["orders.>"],
stream_name: "ORDERS",
consumer_name: "windmill-consumer",
error_handler_path: "f/handlers/on_error",
error_handler_args: {},
retry: { constant: { attempts: 5, seconds: 30 } },
}),
{ type: "trigger", triggerKind: "nats" }
);
expect(result.errors).toHaveLength(0);
});
it("validates sqs trigger with message attributes and oidc auth", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/my-queue",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "oidc",
message_attributes: ["traceId", "source"],
error_handler_path: "f/handlers/on_error",
}),
{ type: "trigger", triggerKind: "sqs" }
);
expect(result.errors).toHaveLength(0);
});
it("validates gcp trigger with push delivery config", () => {
const result = validator.validate(
JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "topic-a",
subscription_id: "sub-push",
delivery_type: "push",
subscription_mode: "create_update",
delivery_config: {
authenticate: true,
base_endpoint: "https://app.example.com/webhook",
audience: "https://app.example.com",
},
error_handler_path: "f/handlers/on_error",
retry: { retry_if: { expr: "error.message.includes('quota')" } },
}),
{ type: "trigger", triggerKind: "gcp" }
);
expect(result.errors).toHaveLength(0);
});
});
// ── Realistic user mistakes ────────────────────────────────────────────
describe("realistic user mistakes", () => {
it("catches invalid enum values across trigger types", () => {
// User typos an http_method as uppercase
const httpResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/webhook",
request_type: "sync",
authentication_method: "none",
http_method: "POST",
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
}),
{ type: "trigger", triggerKind: "http" }
);
expect(httpResult.errors.length).toBeGreaterThan(0);
// User writes "iam_role" instead of "oidc" or "credentials"
const sqsResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/sqs_handler",
is_flow: false,
queue_url: "https://sqs.us-east-1.amazonaws.com/12345/q",
aws_resource_path: "f/resources/aws",
aws_auth_resource_type: "iam_role",
}),
{ type: "trigger", triggerKind: "sqs" }
);
expect(sqsResult.errors.length).toBeGreaterThan(0);
// User writes "stream" instead of "push" or "pull"
const gcpResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "t",
subscription_id: "s",
delivery_type: "stream",
subscription_mode: "existing",
}),
{ type: "trigger", triggerKind: "gcp" }
);
expect(gcpResult.errors.length).toBeGreaterThan(0);
// User writes "v4" instead of "v3" or "v5"
const mqttResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [{ topic: "t", qos: "qos1" }],
client_version: "v4",
}),
{ type: "trigger", triggerKind: "mqtt" }
);
expect(mqttResult.errors.length).toBeGreaterThan(0);
});
it("catches malformed nested structures", () => {
// MQTT topic entry missing qos
const mqttResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/mqtt_handler",
is_flow: false,
mqtt_resource_path: "f/resources/mqtt",
subscribe_topics: [{ topic: "test/topic" }],
}),
{ type: "trigger", triggerKind: "mqtt" }
);
expect(mqttResult.errors.length).toBeGreaterThan(0);
// HTTP static_asset_config without required s3 field
const httpResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/http_handler",
is_flow: false,
route_path: "api/assets",
request_type: "sync",
authentication_method: "none",
http_method: "get",
is_static_website: true,
workspaced_route: false,
wrap_body: false,
raw_string: false,
static_asset_config: { filename: "index.html" },
}),
{ type: "trigger", triggerKind: "http" }
);
expect(httpResult.errors.length).toBeGreaterThan(0);
// GCP delivery_config without required authenticate / base_endpoint
const gcpResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/gcp_handler",
is_flow: false,
gcp_resource_path: "f/resources/gcp",
topic_id: "t",
subscription_id: "s",
delivery_type: "push",
subscription_mode: "create_update",
delivery_config: { audience: "test" },
}),
{ type: "trigger", triggerKind: "gcp" }
);
expect(gcpResult.errors.length).toBeGreaterThan(0);
// Websocket filter missing key
const wsResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/ws_handler",
is_flow: false,
url: "wss://example.com/socket",
filters: [{ value: "test" }],
can_return_message: false,
can_return_error_result: true,
}),
{ type: "trigger", triggerKind: "websocket" }
);
expect(wsResult.errors.length).toBeGreaterThan(0);
});
it("catches wrong types in YAML (string-for-boolean, object-for-array)", () => {
// is_flow: "false" — YAML without quotes would parse to boolean,
// but a quoted "false" parses as string
const natsResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/nats_handler",
is_flow: "false",
nats_resource_path: "f/resources/nats",
use_jetstream: "yes",
subjects: ["events.>"],
}),
{ type: "trigger", triggerKind: "nats" }
);
expect(natsResult.errors.length).toBeGreaterThanOrEqual(2);
// topics as {} instead of []
const kafkaResult = validator.validate(
JSON.stringify({
script_path: "f/triggers/kafka_handler",
is_flow: false,
kafka_resource_path: "f/resources/kafka",
group_id: "g",
topics: {},
filters: "not-an-array",
}),
{ type: "trigger", triggerKind: "kafka" }
);
expect(kafkaResult.errors.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("getValidationTargetFromFilename", () => {
it("detects flow files", () => {
expect(getValidationTargetFromFilename("f/my.flow/flow.yaml")).toEqual({
type: "flow",
});
expect(getValidationTargetFromFilename("flow.yml")).toEqual({
type: "flow",
});
});
it("detects schedule files", () => {
expect(
getValidationTargetFromFilename("f/folder/daily.schedule.yaml")
).toEqual({
type: "schedule",
});
});
it("detects all 9 trigger kinds", () => {
const kinds = [
"http",
"websocket",
"kafka",
"nats",
"postgres",
"mqtt",
"sqs",
"gcp",
"email",
] as const;
for (const kind of kinds) {
expect(
getValidationTargetFromFilename(
`f/triggers/handler.${kind}_trigger.yaml`
)
).toEqual({
type: "trigger",
triggerKind: kind,
});
}
});
it("is case-insensitive for extensions", () => {
expect(getValidationTargetFromFilename("f/my.flow/flow.YAML")).toEqual({
type: "flow",
});
expect(
getValidationTargetFromFilename("f/folder/daily.schedule.YML")
).toEqual({
type: "schedule",
});
});
it("handles dots in directory names", () => {
expect(
getValidationTargetFromFilename("f/my.app.flow/flow.yaml")
).toEqual({
type: "flow",
});
});
it("returns null for unsupported trigger kinds and non-windmill files", () => {
expect(getValidationTargetFromFilename("README.md")).toBeNull();
expect(getValidationTargetFromFilename("f/folder/script.py")).toBeNull();
expect(
getValidationTargetFromFilename("f/folder/resource.yaml")
).toBeNull();
});
});

View File

@@ -12,9 +12,18 @@ value:
value:
type: aiagent
input_transforms:
prompt:
provider:
type: static
value:
kind: openai
resource: "$res:u/admin/openai"
model: gpt-4o-mini
user_message:
type: static
value: "Please analyze the data"
output_type:
type: static
value: text
tools:
- id: tool_1
summary: Data fetcher

View File

@@ -6,9 +6,18 @@ value:
value:
type: aiagent
input_transforms:
task:
provider:
type: static
value:
kind: openai
resource: "$res:u/admin/openai"
model: gpt-4o-mini
user_message:
type: static
value: "Search for information"
output_type:
type: static
value: text
tools:
- id: mcp_search
summary: Search tool from MCP

View File

@@ -11,9 +11,18 @@ value:
value:
type: aiagent
input_transforms:
query:
provider:
type: static
value:
kind: openai
resource: "$res:u/admin/openai"
model: gpt-4o-mini
user_message:
type: javascript
expr: "flow_input.user_query"
output_type:
type: static
value: text
tools:
- id: custom_script
value:

View File

@@ -7,12 +7,18 @@ value:
type: aiagent
parallel: true
input_transforms:
instruction:
provider:
type: static
value:
kind: openai
resource: "$res:u/admin/openai"
model: gpt-4o-mini
user_message:
type: static
value: "Process data in parallel"
model:
type: javascript
expr: "'gpt-4'"
output_type:
type: static
value: text
tools:
- id: parallel_tool_1
summary: First parallel tool

View File

@@ -1,50 +0,0 @@
import Ajv, { AnySchema, ErrorObject, ValidateFunction } from 'ajv';
import { parseWithPointers, YamlParserResult } from '@stoplight/yaml';
import openFlowSchema from '../gen/openflow.json';
/**
* Flow validator class that initializes AJV once and reuses it for validation.
*/
export class FlowValidator {
private readonly validate: ValidateFunction;
constructor() {
const ajv = new Ajv({ strict: false, allErrors: true, discriminator: true });
for (const [n, s] of Object.entries(openFlowSchema.components.schemas)) {
ajv.addSchema(s as AnySchema, `#/components/schemas/${n}`);
}
this.validate = ajv.getSchema('#/components/schemas/OpenFlow')!;
}
/**
* Validates a flow document against the OpenFlow schema.
* @param doc - The YAML flow document as a string
* @returns Object containing the parsed document and any validation errors
*/
validateFlow(doc: string): {
parsed: YamlParserResult<unknown>;
errors: ErrorObject[];
} {
if (typeof doc !== 'string') {
throw new Error('Document must be a string');
}
const parsed = parseWithPointers(doc);
const { data } = parsed;
const ok = this.validate(data);
if (ok) {
return {
parsed,
errors: [],
};
}
return {
parsed,
errors: this.validate.errors!,
};
}
}

View File

@@ -1 +1 @@
export * from './flow-validator';
export * from "./yaml-validator";

View File

@@ -0,0 +1,141 @@
import Ajv, { AnySchema, ErrorObject, ValidateFunction } from "ajv";
import { parseWithPointers, YamlParserResult } from "@stoplight/yaml";
import openFlowSchema from "../gen/openflow.json";
import scheduleSchema from "../gen/schedule.json";
import gcpTriggerSchema from "../gen/triggers/gcp.json";
import httpTriggerSchema from "../gen/triggers/http.json";
import kafkaTriggerSchema from "../gen/triggers/kafka.json";
import mqttTriggerSchema from "../gen/triggers/mqtt.json";
import natsTriggerSchema from "../gen/triggers/nats.json";
import postgresTriggerSchema from "../gen/triggers/postgres.json";
import sqsTriggerSchema from "../gen/triggers/sqs.json";
import websocketTriggerSchema from "../gen/triggers/websocket.json";
import emailTriggerSchema from "../gen/triggers/email.json";
export const SUPPORTED_TRIGGER_KINDS = [
"http",
"websocket",
"kafka",
"nats",
"postgres",
"mqtt",
"sqs",
"gcp",
"email",
] as const;
export type TriggerKind = (typeof SUPPORTED_TRIGGER_KINDS)[number];
export type ValidationTarget =
| { type: "flow" }
| { type: "schedule" }
| { type: "trigger"; triggerKind: TriggerKind };
const TRIGGER_SCHEMAS: Record<TriggerKind, AnySchema> = {
http: httpTriggerSchema as AnySchema,
websocket: websocketTriggerSchema as AnySchema,
kafka: kafkaTriggerSchema as AnySchema,
nats: natsTriggerSchema as AnySchema,
postgres: postgresTriggerSchema as AnySchema,
mqtt: mqttTriggerSchema as AnySchema,
sqs: sqsTriggerSchema as AnySchema,
gcp: gcpTriggerSchema as AnySchema,
email: emailTriggerSchema as AnySchema,
};
/**
* Infers validation target from file name conventions used by Windmill sync.
*/
export function getValidationTargetFromFilename(
filePath: string
): ValidationTarget | null {
const path = filePath.toLowerCase();
if (/[/\\]flow\.ya?ml$/.test(path) || /^flow\.ya?ml$/.test(path)) {
return { type: "flow" };
}
if (/\.schedule\.ya?ml$/.test(path)) {
return { type: "schedule" };
}
const triggerMatch = path.match(
/\.(http|websocket|kafka|nats|postgres|mqtt|sqs|gcp|email)_trigger\.ya?ml$/
);
if (triggerMatch) {
return {
type: "trigger",
triggerKind: triggerMatch[1] as TriggerKind,
};
}
return null;
}
/**
* Unified YAML validator for Windmill flow, schedule, and trigger files.
*/
export class WindmillYamlValidator {
private readonly validateFlow: ValidateFunction;
private readonly validateSchedule: ValidateFunction;
private readonly validateTrigger: Record<TriggerKind, ValidateFunction>;
constructor() {
const ajv = new Ajv({
strict: false,
allErrors: true,
discriminator: true,
validateFormats: false,
});
for (const [name, schema] of Object.entries(openFlowSchema.components.schemas)) {
ajv.addSchema(schema as AnySchema, `#/components/schemas/${name}`);
}
this.validateFlow = ajv.getSchema("#/components/schemas/OpenFlow")!;
this.validateSchedule = ajv.compile(scheduleSchema as AnySchema);
this.validateTrigger = Object.fromEntries(
SUPPORTED_TRIGGER_KINDS.map((kind) => [kind, ajv.compile(TRIGGER_SCHEMAS[kind])])
) as Record<TriggerKind, ValidateFunction>;
}
/**
* Validates a Windmill YAML document based on the selected target.
* @param doc - The YAML document as string
* @param target - Which Windmill schema to validate against
*/
validate(
doc: string,
target: ValidationTarget
): { parsed: YamlParserResult<unknown>; errors: ErrorObject[] } {
if (typeof doc !== "string") {
throw new Error("Document must be a string");
}
const parsed = parseWithPointers(doc);
const { data } = parsed;
let validator: ValidateFunction;
if (target.type === "flow") {
validator = this.validateFlow;
} else if (target.type === "schedule") {
validator = this.validateSchedule;
} else {
validator = this.validateTrigger[target.triggerKind];
if (!validator) {
throw new Error(`Unsupported trigger kind: ${target.triggerKind}`);
}
}
const ok = validator(data);
if (ok) {
return { parsed, errors: [] };
}
return {
parsed,
errors: validator.errors || [],
};
}
}