Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion capabilities/coordination/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ A run a caller starts, one whose context names no `trigger`, whether `run_defini

`dslProblems` (`src/dsl/dsl-validation.ts`) checks a document against the DSL, before the policy's rejections join its problems, and answers a promise, which the document's reader awaits: the version it names, its names, the schema of the specification, the names of its tasks and the graph of its steps, in that order, each only when those before it found nothing. It reads the document without the runtime's own `output.shape` and without the `kind` and `because` of an error, the runtime's extensions to the DSL's errors.

The schema is `workflowSchema` of `@openworkflowspec/sdk`, the specification's JSON Schema of draft 2020-12, and `@hyperjump/json-schema`, its `draft-2020-12` entry, checks a document against it by interpreting it (`src/dsl/specification-schema.ts`). The SDK's own validation, which `Classes.Workflow.deserialize` runs unless it is given `{ validate: false }`, is Ajv, which compiles each of the SDK's 171 validators into code generated from strings: it costs start-up time wherever it loads and is refused wherever no code may be generated. The interpreting validator reads the same schema as data, generates no code, and compiles the schema into the tree it interprets only when the first document is checked, in about 35 ms; its validation is asynchronous only for that, so the capability's check is a promise. It asserts the formats the schema names, `uri`, `uri-template`, `json-pointer` and `date-time`, registered with the checks of `@hyperjump/json-schema-formats` the library's own formats use, and asserted with `setShouldValidateFormat(true)`, a setting of the library for the whole process, both made once, as the library is loaded (`src/dsl/dsl-libraries.ts`), since Ajv asserted them too and draft 2020-12 otherwise reads a format as an annotation. It gives the same answers: over the 499 workflow documents the runtime's tests check, its corpus and its input logs hold, and the 13,340 made from them by changing one value each, it accepts and refuses exactly the documents Ajv does. Its `date-time` is stricter than Ajv's: it refuses a space or a tab between the date and the time, which the grammar of RFC 3339 does not allow, and a leap second on a day that had none. Nothing calls the SDK's validation; the SDK stays for its schema, its classes and its graph, and importing it still compiles its validators. So the capability imports the SDK, and the validator with the formats it asserts, with the first document it reads, through `specificationSdk` and `schemaValidator` (`src/dsl/dsl-libraries.ts`), the one place each is loaded, and keeps them while the server runs; no module the capability's entry imports reaches either (`src/dsl/library-entries.test.ts`). A server that reads no workflow document never loads them, which took 130 ms, 222 files and 1.4 MiB of a server's start, and the first document read pays for them beside the compiling of the schema: the first save of a workflow without expressions took 175 ms against 34 ms when the two loaded with the capability, and the second 5.2 ms against 4.9 ms (measured on 2026-10-23 on one machine of 16 cores, with Node 26.10.0, at the median of 11 starts and of 9 servers).
The schema is `workflowSchema` of `@openworkflowspec/sdk`, the specification's JSON Schema of draft 2020-12, and `@hyperjump/json-schema`, its `draft-2020-12` entry, checks a document against it by interpreting it (`src/dsl/specification-schema.ts`). The SDK's own validation, which `Classes.Workflow.deserialize` runs unless it is given `{ validate: false }`, is Ajv, which compiles each of the SDK's 171 validators into code generated from strings: it costs start-up time wherever it loads and is refused wherever no code may be generated. The interpreting validator reads the same schema as data, generates no code, and compiles the schema into the tree it interprets only when the first document is checked, in about 35 ms; its validation is asynchronous only for that, so the capability's check is a promise. It asserts the formats the schema names, `uri`, `uri-template`, `json-pointer` and `date-time`, registered with the checks of `@hyperjump/json-schema-formats` the library's own formats use, and asserted with `setShouldValidateFormat(true)`, a setting of the library for the whole process, both made once, as the library is loaded (`src/dsl/dsl-libraries.ts`), since Ajv asserted them too and draft 2020-12 otherwise reads a format as an annotation. It gives the same answers: over the 499 workflow documents the runtime's tests check, its corpus and its input logs hold, and the 13,340 made from them by changing one value each, it accepts and refuses exactly the documents Ajv does. Its `date-time` is stricter than Ajv's: it refuses a space or a tab between the date and the time, which the grammar of RFC 3339 does not allow, and a leap second on a day that had none. Nothing calls the SDK's validation; the SDK stays for its schema, its classes and its graph. The capability imports the SDK, and the validator with the formats it asserts, with the first document it reads, through `specificationSdk` and `schemaValidator` (`src/dsl/dsl-libraries.ts`), the one place each is loaded, and keeps them while the server runs; no module the capability's entry imports reaches either (`src/dsl/library-entries.test.ts`). A server that reads no workflow document never loads them, which took 130 ms, 222 files and 1.4 MiB of a server's start with the SDK as published (measured on 2026-10-23 on one machine of 16 cores, with Node 26.10.0, at the median of 11 starts), and the first document read pays for them beside the compiling of the schema: with the SDK patched, as the next paragraph says, the first save of a workflow without expressions took 82 ms, against 182 ms with the SDK as published and 37 ms when the two loaded with the capability, and the second 5.5 ms, against 5.0 ms and 5.2 ms (measured on 2026-10-23 on the same machine, with Node 26.10.0, at the median of 9 servers of each, interleaved, at a load average of 5.8 to 6.6).

The SDK is patched so that importing it compiles no validator, since the runtime never asks it to validate. As published, `@openworkflowspec/sdk` 1.0.3-alpha8 builds its Ajv instance and compiles all 171 of its validators as it is imported, which generates 217 functions from strings. `patches/@openworkflowspec__sdk@1.0.3-alpha8.patch`, which `pnpm-workspace.yaml` names and the image copies before it installs its dependencies, makes `index.mjs`, `index.cjs` and `index.umd.js` build the instance and compile the validator of a type the first time `validate` is called for that type, and keep it, with the same errors for a type the SDK does not know and a schema it cannot find. The minified bundle, `index.umd.min.js`, which only the package's `browser` field names and Node does not load, is left as published. A patch applies only where this workspace installs its dependencies, so the npm package `@on.auto/runtime` depends on the SDK as published. Patched, importing the SDK, hydrating a workflow and building its graph generate no function from strings, and the first `validate` of a workflow generates 47; the import took 19 ms against 119 ms as published, and 28 ms of CPU against 174 ms (measured on 2026-10-23 on the same machine, with Node 26.10.0, at the median of 11 imports of each, interleaved, at a load average of 5.5 to 5.8). In a process run with `--disallow-code-generation-from-strings`, where as published it cannot be imported, the patched SDK loads, hydrates a workflow, builds its graph and writes its Mermaid as it does anywhere else, and refuses only to validate (`src/dsl/specification-sdk.test.ts`). The patch goes when the SDK itself compiles on first use.

The schema is read with two changes that keep what it accepts. A `false` subschema is read as `{ not: {} }`, which refuses the same values, so that every refusal is a keyword of a schema at its place and a property the schema refuses outright is a `not` that allows nothing, wherever the schema refuses it. A task is checked on its own: each reference to `#/$defs/task` is read as any object, and each task `allTaskEntries` of `@on.auto/workflow-engine` walks is checked against `#/$defs/task`, so a document is checked once a task. Checked whole, a list of tasks is checked again for each type of task that may hold it, `do` and `for` among them, and the time doubles with each level the tasks nest, on the one thread every workflow shares: Ajv took 55 ms for 16 levels and about a second for 20; task by task, 30 levels take about a millisecond (`src/dsl/dsl-validation.test.ts`). A task under `use.functions`, which the policy refuses, is not checked further.

Expand Down
62 changes: 62 additions & 0 deletions capabilities/coordination/src/dsl/specification-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { once } from 'node:events';

import { spawnForTheTest } from '@on.auto/operations/testing';
import { Schema } from 'effect';
import { describe, expect, it } from 'vitest';

import { workflow } from '../testing/workflows.ts';

const greeting = workflow('do:\n - greet: { set: { greeting: hello } }\n - answer: { set: { answered: true } }');

const usingTheSdk = [
`const { buildGraph, Classes, convertToMermaidCode, validate } = await import(${JSON.stringify(import.meta.resolve('@openworkflowspec/sdk'))});`,
`const workflow = Classes.Workflow.deserialize(${JSON.stringify(JSON.stringify(greeting))}, { validate: false });`,
'const nodes = buildGraph(workflow).nodes.map(({ id }) => id);',
'const mermaid = convertToMermaidCode(workflow);',
"const validation = (() => { try { validate('Workflow', workflow); return 'passed'; } catch (error) { return String(error); } })();",
'process.stdout.write(JSON.stringify({ nodes, mermaid, validation }));',
].join('\n');

const useOf = Schema.decodeUnknownSync(
Schema.fromJsonString(
Schema.Struct({ nodes: Schema.Array(Schema.String), mermaid: Schema.String, validation: Schema.String }),
),
);

interface Ended {
readonly exitCode: number | null;
readonly printed: string;
}

async function theSdkUsedWith(flags: readonly string[]): Promise<Ended> {
const child = spawnForTheTest(process.execPath, [...flags, '--input-type=module', '--eval', usingTheSdk], {
stdio: ['ignore', 'pipe', 'ignore'],
});
const output = { printed: '' };
child.stdout?.setEncoding('utf8').on('data', (chunk: string) => {
output.printed += chunk;
});
await once(child, 'close');
return { exitCode: child.exitCode, printed: output.printed };
}

const flowchart: unknown = expect.stringMatching(/^flowchart TD\n/u);

describe('the SDK of the specification', () => {
it('loads where no code may be generated from strings, hydrates a workflow, builds its graph and writes its Mermaid there as anywhere else, and refuses only to validate', async () => {
const anywhere = await theSdkUsedWith([]);
const withoutCodeFromStrings = await theSdkUsedWith(['--disallow-code-generation-from-strings']);

expect([anywhere.exitCode, withoutCodeFromStrings.exitCode]).toEqual([0, 0]);
expect(useOf(anywhere.printed)).toEqual({
nodes: ['root-entry-node', 'root-exit-node', '/do/greet', '/do/answer'],
mermaid: flowchart,
validation: 'passed',
});
expect(useOf(withoutCodeFromStrings.printed)).toEqual({
...useOf(anywhere.printed),
validation:
"DslValidationError: Validation failed at '<root>' (Workflow): Code generation from strings disallowed for this context",
});
});
});
1 change: 1 addition & 0 deletions packages/server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ RUN apt-get update \
WORKDIR /app
RUN npm install --global --no-fund --no-audit pnpm@12.11.2
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches patches/
COPY packages/server/package.json packages/server/
COPY packages/api/package.json packages/api/
COPY packages/playbooks/package.json packages/playbooks/
Expand Down
Loading