From 28a636bdc7722c28718bd9ec4a3c2f9a34ff1a2f Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 19:10:16 +0100 Subject: [PATCH 1/8] docs(dsl): design and implementation plan for declarative workflow authoring Adds a second authoring path: a workflow as a JSON document, validated against a published schema and interpreted onto the existing runtime. The governing rule is that the DSL composes but never computes. A document declares which nodes exist, how they connect, and when an edge is taken; it carries no behaviour. Every unit of work is a capability the host already shipped, reached either as a built-in kind or as a custom node registered by name. Three decisions follow from putting JSON in front of a generic, delegate- shaped API: - One envelope type. Every node is HostExecutor, so the graph is uniformly typed, checkpoints serialize for free, and the null-return park path keeps working. The envelope carries the start context alongside the current value, restoring the ambient scope a document otherwise lacks. - A closed expression language: total, pure, statically checkable, with a fixed function set. Non-deterministic functions are refused in edge conditions and gate predicates, because a resumed run must retrace the routing its checkpoint recorded. - A named node catalog with a registration seam, and no delegate kind. Validation is two phases: JSON Schema for shape, then a semantic validator for what a schema cannot express - id uniqueness, reachability, cycles without a delay, expression parsing, catalog resolution. Every diagnostic carries a JSON Pointer. The schema is published rather than described. It checks as legal Draft 2020-12, accepts the worked example, and rejects 17 malformed variants covering each conditional branch it declares. The plan keeps the core change to one additive opt-in interface, IContextValidatingWorkflow, consulted by the registry after the type bind. Runtime publication, sub-workflows and iteration are named as deferred, with reasons. --- docs/schema/abacus-workflow-dsl-1.0.json | 444 +++++++++++++++++++++++ docs/workflow-dsl-design.md | 438 ++++++++++++++++++++++ docs/workflow-dsl-implementation-plan.md | 347 ++++++++++++++++++ 3 files changed, 1229 insertions(+) create mode 100644 docs/schema/abacus-workflow-dsl-1.0.json create mode 100644 docs/workflow-dsl-design.md create mode 100644 docs/workflow-dsl-implementation-plan.md diff --git a/docs/schema/abacus-workflow-dsl-1.0.json b/docs/schema/abacus-workflow-dsl-1.0.json new file mode 100644 index 0000000..25151fb --- /dev/null +++ b/docs/schema/abacus-workflow-dsl-1.0.json @@ -0,0 +1,444 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://abacus.run/schema/abacus-workflow-dsl-1.0.json", + "title": "Abacus workflow DSL", + "description": "Declarative workflow definition. Phase 1 (structural) validation only; graph reachability, expression parsing, and catalog resolution are the semantic validator's job.", + "type": "object", + "required": ["dsl", "name", "version", "start", "nodes"], + "additionalProperties": false, + + "properties": { + "dsl": { + "description": "Media identifier selecting schema and interpreter. Major version must match.", + "const": "abacus.workflow/1.0" + }, + "name": { "$ref": "#/$defs/workflowName" }, + "version": { "$ref": "#/$defs/semver" }, + "description": { "type": "string", "maxLength": 1024 }, + + "context": { + "description": "JSON Schema the start payload must satisfy. Enforced by IContextValidatingWorkflow.", + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + "result": { + "description": "JSON Schema the workflow result is expected to satisfy. Advisory unless strict.", + "$ref": "https://json-schema.org/draft/2020-12/schema" + }, + "strict": { + "description": "Enforce declared node input/output schemas at run time. A violation is dead-stop.", + "type": "boolean", + "default": false + }, + + "start": { "$ref": "#/$defs/nodeId" }, + "output": { + "description": "Nodes whose result binds the workflow output. Defaults to terminal nodes.", + "type": "array", + "items": { "$ref": "#/$defs/nodeId" }, + "minItems": 1, + "uniqueItems": true + }, + + "nodes": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { "$ref": "#/$defs/node" } + }, + "edges": { + "type": "array", + "maxItems": 2000, + "items": { "$ref": "#/$defs/edge" } + }, + + "triggers": { "type": "array", "items": { "$ref": "#/$defs/trigger" } }, + "notifications": { "$ref": "#/$defs/notifications" }, + "onFailure": { "type": "array", "items": { "$ref": "#/$defs/failureRule" } }, + "audit": { "$ref": "#/$defs/audit" }, + "limits": { "$ref": "#/$defs/limits" } + }, + + "$defs": { + "workflowName": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$", + "description": "Lowercase kebab-case. Matches the registry's resolution key." + }, + "nodeId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$", + "description": "Stable identity: gate policy, node state and per-node notification overrides all key off it. Renaming one in a published version orphans tenant policy." + }, + "semver": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" + }, + "duration": { + "type": "string", + "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$", + "description": "ISO-8601 duration, e.g. PT8H." + }, + "expression": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "AbEx expression. Roots: $ (current data), $ctx (start context), $run (run metadata). Parsed by the semantic validator." + }, + "template": { + "type": "string", + "maxLength": 65536, + "description": "String with {{ expression }} interpolation." + }, + "topicPattern": { + "type": "string", + "pattern": "^[A-Za-z0-9_.*#-]+$", + "description": "Dot-segmented topic. '*' matches one segment; '#' matches the remainder and may appear only last." + }, + "principal": { + "type": "string", + "pattern": "^(user|group|role):[A-Za-z0-9._@-]+$" + }, + + "gate": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { "enum": ["autonomous", "requireApproval", "conditional"] }, + "when": { "$ref": "#/$defs/expression" }, + "reason": { "type": "string", "maxLength": 256 }, + "assignTo": { "type": "array", "items": { "$ref": "#/$defs/principal" } }, + "requireApprovers": { "type": "integer", "minimum": 1, "default": 1 }, + "expiresAfter": { "$ref": "#/$defs/duration", "default": "PT24H" }, + "onExpiry": { + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "action": { "enum": ["deadStop", "reject", "autoApprove", "escalate"] }, + "assignTo": { "type": "array", "items": { "$ref": "#/$defs/principal" } } + } + }, + "allowModification": { "type": "boolean", "default": false }, + "requireSegregationOfDuties": { "type": "boolean", "default": false }, + "locked": { + "type": "boolean", + "default": false, + "description": "Author's floor. Tenants may tighten, never loosen." + } + }, + "allOf": [ + { + "if": { "properties": { "mode": { "const": "conditional" } }, "required": ["mode"] }, + "then": { "required": ["when"] } + }, + { + "if": { "properties": { "onExpiry": { "properties": { "action": { "const": "escalate" } }, "required": ["action"] } }, "required": ["onExpiry"] }, + "then": { "properties": { "onExpiry": { "required": ["assignTo"] } } } + } + ] + }, + + "nodeCommon": { + "type": "object", + "properties": { + "id": { "$ref": "#/$defs/nodeId" }, + "kind": { "type": "string" }, + "description": { "type": "string", "maxLength": 512 }, + "input": { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + "output": { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + "gate": { "$ref": "#/$defs/gate" }, + "notify": { + "description": "Workflow-defined notification emitted after this node succeeds. Name is prefixed 'custom.' by the framework.", + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Za-z0-9]+(\\.[A-Za-z0-9]+)*$" }, + "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/expression" } } + } + } + } + }, + + "node": { + "type": "object", + "required": ["id", "kind"], + "allOf": [ + { "$ref": "#/$defs/nodeCommon" }, + { + "properties": { + "kind": { + "enum": ["transform", "http", "llm", "delay", "approval", "publish", "wait-event", "fan-in", "custom"] + } + } + }, + + { + "if": { "properties": { "kind": { "const": "transform" } }, "required": ["kind"] }, + "then": { + "required": ["set"], + "properties": { + "set": { + "description": "Target path within data -> AbEx expression. Applied to a copy; source paths read the pre-transform value.", + "type": "object", + "minProperties": 1, + "additionalProperties": { "$ref": "#/$defs/expression" } + }, + "replace": { + "type": "boolean", + "default": false, + "description": "Replace data entirely rather than merging into it." + } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "http" } }, "required": ["kind"] }, + "then": { + "required": ["url"], + "properties": { + "method": { "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"], "default": "GET" }, + "url": { "$ref": "#/$defs/template" }, + "headers": { "type": "object", "additionalProperties": { "$ref": "#/$defs/template" } }, + "body": { "$ref": "#/$defs/template" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600, "default": 30 }, + "successCodes": { "type": "array", "items": { "type": "integer", "minimum": 100, "maximum": 599 } }, + "allowedHosts": { "type": "array", "items": { "type": "string", "format": "hostname" } }, + "sendIdempotencyKey": { "type": "boolean", "default": true } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "llm" } }, "required": ["kind"] }, + "then": { + "required": ["model", "prompt"], + "properties": { + "model": { "type": "string", "minLength": 1 }, + "system": { "$ref": "#/$defs/template" }, + "prompt": { "$ref": "#/$defs/template" }, + "promptVersion": { "type": "string", "maxLength": 64 }, + "structuredOutput": { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + "temperature": { "type": "number", "minimum": 0, "maximum": 2 }, + "maxTokens": { "type": "integer", "minimum": 1 }, + "streamDeltas": { "type": "boolean", "default": false }, + "emitCompletion": { "type": "boolean", "default": true } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "delay" } }, "required": ["kind"] }, + "then": { + "required": ["for"], + "properties": { + "for": { "$ref": "#/$defs/duration" } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "publish" } }, "required": ["kind"] }, + "then": { + "required": ["topic"], + "properties": { + "topic": { "$ref": "#/$defs/topicPattern" }, + "payload": { "type": "object", "additionalProperties": { "$ref": "#/$defs/expression" } }, + "correlationKey": { "$ref": "#/$defs/expression" }, + "scope": { "enum": ["local", "distributed"], "default": "local" } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "wait-event" } }, "required": ["kind"] }, + "then": { + "required": ["topic"], + "properties": { + "topic": { "$ref": "#/$defs/topicPattern" }, + "correlationKey": { "$ref": "#/$defs/expression" }, + "timeout": { "$ref": "#/$defs/duration" }, + "onExpiry": { "enum": ["deadStop", "resume"], "default": "deadStop" } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "fan-in" } }, "required": ["kind"] }, + "then": { + "properties": { + "into": { + "type": "string", + "default": "items", + "description": "Path within data receiving the aggregated array." + } + } + } + }, + + { + "if": { "properties": { "kind": { "const": "custom" } }, "required": ["kind"] }, + "then": { + "required": ["node"], + "properties": { + "node": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$", + "description": "Name registered via AddDslNode. Resolved at startup; unknown names fail registration." + }, + "with": { + "type": "object", + "description": "Validated against the factory's own published schema by the semantic validator." + } + } + } + }, + + { + "$comment": "A raw-equivalent node cannot be gated, mirroring RawNode in the compiled API.", + "if": { "properties": { "kind": { "const": "fan-in" } }, "required": ["kind"] }, + "then": { "not": { "required": ["gate"] } } + } + ] + }, + + "edge": { + "type": "object", + "required": ["from", "to"], + "additionalProperties": false, + "properties": { + "from": { + "oneOf": [ + { "$ref": "#/$defs/nodeId" }, + { "type": "array", "items": { "$ref": "#/$defs/nodeId" }, "minItems": 2, "uniqueItems": true } + ], + "description": "An array means a fan-in barrier: the target runs once every source has delivered." + }, + "to": { + "oneOf": [ + { "$ref": "#/$defs/nodeId" }, + { "type": "array", "items": { "$ref": "#/$defs/nodeId" }, "minItems": 2, "uniqueItems": true } + ], + "description": "An array means fan-out to every target, or to the subset 'select' picks." + }, + "when": { + "$ref": "#/$defs/expression", + "description": "Edge is traversed only when this evaluates to boolean true. Must be deterministic." + }, + "select": { + "$ref": "#/$defs/expression", + "description": "Fan-out only: yields the indices of targets to send to." + }, + "label": { "type": "string", "maxLength": 64 }, + "idempotent": { "type": "boolean", "default": false } + }, + "allOf": [ + { + "$comment": "A barrier has one target and no condition; conditions on a barrier are ambiguous.", + "if": { "properties": { "from": { "type": "array" } }, "required": ["from"] }, + "then": { + "properties": { "to": { "$ref": "#/$defs/nodeId" } }, + "not": { "anyOf": [{ "required": ["when"] }, { "required": ["select"] }] } + } + }, + { + "$comment": "'select' only means something when there are several targets.", + "if": { "required": ["select"] }, + "then": { "properties": { "to": { "type": "array" } } } + } + ] + }, + + "trigger": { + "type": "object", + "required": ["topic"], + "additionalProperties": false, + "properties": { + "topic": { "$ref": "#/$defs/topicPattern" }, + "correlationKey": { "$ref": "#/$defs/expression" }, + "contextFrom": { + "$ref": "#/$defs/expression", + "description": "Builds the start context from the triggering message. Defaults to the whole payload." + } + } + }, + + "notifications": { + "type": "object", + "additionalProperties": false, + "properties": { + "level": { "enum": ["minimal", "lifecycle", "standard"], "default": "standard" }, + "stream": { + "type": "boolean", + "default": true, + "description": "Live SSE fan-out. The durable event log is not optional and cannot be disabled." + }, + "byNode": { + "type": "object", + "additionalProperties": { "enum": ["minimal", "lifecycle", "standard"] } + }, + "emits": { + "type": "array", + "items": { "type": "string", "pattern": "^[A-Za-z0-9]+(\\.[A-Za-z0-9]+)*$" }, + "description": "Workflow-defined names, declared without the 'custom.' prefix." + } + } + }, + + "failureRule": { + "type": "object", + "required": ["match", "disposition"], + "additionalProperties": false, + "properties": { + "match": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "exception": { + "enum": [ + "WorkflowDeadStopException", + "ApprovalRejectedException", + "WorkflowValidationException", + "StructuredOutputException", + "ApiCallFailureException", + "LlmRateLimitException", + "LlmOverloadedException", + "DslContractException" + ], + "description": "Whitelist. A document cannot name arbitrary types." + }, + "status": { "type": "string", "pattern": "^([1-5]xx|[1-5]\\d{2})$" }, + "node": { "$ref": "#/$defs/nodeId" } + } + }, + "disposition": { "enum": ["retry", "deadStop", "escalate"] } + } + }, + + "audit": { + "type": "object", + "required": ["sections"], + "additionalProperties": false, + "properties": { + "key": { "$ref": "#/$defs/expression", "description": "Business key the record is opened under." }, + "sections": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" } + } + } + }, + + "limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxAttempts": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }, + "maxLifetimeHours": { "type": "integer", "minimum": 1 } + } + } + } +} diff --git a/docs/workflow-dsl-design.md b/docs/workflow-dsl-design.md new file mode 100644 index 0000000..bb5252e --- /dev/null +++ b/docs/workflow-dsl-design.md @@ -0,0 +1,438 @@ +# Workflow DSL — design narrative + +Abacus has one way to author a workflow: implement `IWorkflowDefinition` in C#, +compile it, and register it at startup. That path is expressive, type-safe, and closed to anyone who +cannot ship a build. + +This adds a second path. A workflow becomes a **JSON document** — validated against a published +schema, interpreted at build time, and registered exactly like a compiled one. Nothing about the +runtime changes. The DSL is a *front end* onto the same graph, the same executors, the same gates. + +--- + +## 1. The governing rule + +> **The DSL composes; it never computes.** +> +> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never +> carries behaviour. Every unit of work a DSL workflow performs is a capability the host already +> shipped and vetted — a built-in executor, or a custom node the host registered by name. + +Everything below follows from that sentence. It is what makes a document safe to accept from outside +the build, cheap to validate, and honest about its ceiling: a DSL workflow can only do what the host +already knows how to do, and the answer to "the DSL can't express this" is *register a node*, never +*embed a script*. + +The corollary matters as much: **the DSL is not a replacement for the compiled path.** They are peers +with different centres of gravity. + +| | Compiled definition | DSL document | +| --- | --- | --- | +| **Authored by** | An engineer with a build pipeline | Anyone with the schema | +| **Expresses** | Arbitrary behaviour | Composition of registered behaviour | +| **Typing** | Compile-time, generic | Runtime, JSON Schema per node | +| **Changed by** | A release | An edited document | +| **Ceiling** | The language | The registered node catalog | +| **Best for** | Domain logic, novel executors | Orchestration, per-tenant variation, rapid iteration | + +A realistic system uses both: engineers ship nodes, and workflows wire them together. + +--- + +## 2. What has to be true for JSON to describe this graph + +The compiled API is generic and delegate-shaped. Four features of it do not survive contact with a +document, and each forces a decision. + +**Generic executors.** `HostExecutor` is parameterised, and JSON carries no type +arguments. → *Decision D1: one envelope type.* + +**Delegates everywhere.** Edge conditions, gate predicates, transforms and correlation keys are all +`Func<...>`. → *Decision D2: a closed expression language.* + +**Ambient C# scope.** A compiled node closes over whatever it likes. A document has no scope. +→ *Decision D3: the envelope carries the run's context explicitly.* + +**Open-ended work.** `DelegateExecutor` accepts any lambda. A document must not. +→ *Decision D4: a named node catalog with a registration seam.* + +--- + +## 3. D1 — One envelope, uniformly typed + +Every DSL node is a `HostExecutor`. `DslMessage` is a sealed class wrapping a +JSON object: + +```json +{ + "ctx": { "orderId": "ORD-1", "lines": [ … ] }, + "data": { "total": 429.50 }, + "meta": { "node": "price", "superstep": 3 } +} +``` + +- **`ctx`** — the start context, deep-frozen. Copied through every node unchanged. This is D3: it + restores the ambient scope a document otherwise lacks, and it is the only reason an expression + eleven nodes deep can still say `$ctx.orderId`. +- **`data`** — the current value. This is what a node reads and what it replaces. +- **`meta`** — provenance the interpreter maintains. Read-only to expressions. + +Three things fall out of this, and they are the whole argument for it: + +1. **Every edge type-checks by construction.** There is no type-flow analysis to write, because there + is only one type. The engine's own `AddEdge` conditions are always `AddEdge`. +2. **Checkpoint and resume are free.** The envelope is already JSON; there is no serializer to teach + about a DSL workflow's message types. +3. **`TOut : class` is satisfied**, so the null-return park path — the mechanism behind approval + gates and event waits — works for DSL nodes with no change to `HostExecutor`. + +What it costs is compile-time type safety, and the replacement is explicit: a node may declare +`input` and `output` JSON Schemas, enforced at runtime under `"strict": true`. A schema violation +throws `DslContractException`, which classifies as **dead-stop** — a node handed the wrong shape will +be handed it again on retry. + +### The definition's own generic parameters + +`DslWorkflowDefinition` implements `IWorkflowDefinition`. That makes the +registry's type-bind step a no-op, which is correct but insufficient — a DSL document declares a +`context` schema and the registry must honour it. + +This is **the one core change the DSL requires**: an opt-in interface consulted by +`WorkflowRegistry.ValidateContext` after the type bind succeeds. + +```csharp +public interface IContextValidatingWorkflow +{ + ContextValidationResult ValidateContext(JsonElement context); +} +``` + +Additive, opt-in, and useful beyond the DSL — a compiled workflow wanting schema validation of its +start payload gets it the same way. Nothing else in `Abacus.Run` changes to support the DSL. + +--- + +## 4. D2 — AbEx, the expression language + +Conditions, guards, correlation keys and projections all need *some* computation. The requirement is +narrow and the risk is not, so the grammar is closed. + +### Design constraints + +An expression must be **total** (no exceptions — a missing path is a value, not a fault), **pure** +(no I/O, no state), **cheap** (bounded depth, no loops, no recursion), and **statically checkable** +(every function and operator resolved at validation time, so a typo fails a document review rather +than a production run). + +### Grammar + +``` +expr := or +or := and ( "||" and )* +and := unary ( "&&" unary )* +unary := "!" unary | cmp +cmp := add ( ("=="|"!="|"<"|"<="|">"|">=") add )? +add := mul ( ("+"|"-") mul )* +mul := primary ( ("*"|"/"|"%") primary )* +primary := literal | path | call | "(" expr ")" +call := ident "(" [ expr ("," expr)* ] ")" +path := root ( "." ident | "[" integer "]" )* +root := "$" | "$ctx" | "$run" | ident +literal := number | string | "true" | "false" | "null" +``` + +### Roots + +| Root | Binds to | Notes | +| --- | --- | --- | +| `$` | `data` of the current envelope | `$.total`, `$.lines[0].sku` | +| `$ctx` | the frozen start context | available at every node | +| `$run` | `instanceId`, `tenantId`, `attempt`, `superstep`, `workflow`, `version`, `now` | | + +There is deliberately **no `$node.`**. The engine is message-passing; a prior node's output is +not ambiently available, and a root that pretended otherwise would be a lie the interpreter could not +keep. A workflow that needs an earlier value carries it forward in `data` — which is what an explicit +`transform` node is for. + +### Functions + +A closed set. An unknown name is a **validation error**, not a runtime one. + +| Function | Result | +| --- | --- | +| `len(x)` | length of a string or array; `0` for `null` | +| `has(path)` | whether the path resolves to anything other than absent | +| `lower(s)` / `upper(s)` | case folding, invariant culture | +| `contains(s, sub)`, `startsWith(s, p)`, `endsWith(s, p)` | ordinal string tests | +| `matches(s, pattern)` | regex, compiled once, **200 ms match timeout**, non-backtracking where the pattern allows | +| `coalesce(a, b, …)` | first non-absent, non-null argument | +| `number(x)`, `string(x)`, `bool(x)` | explicit coercion | + +`matches` is the one dangerous entry; the timeout and the compile-time pattern check are what earn +its place. If a future review disagrees, it is the removable one. + +### Semantics, stated so there is nothing to guess + +- **Absence is a value.** A path that does not resolve yields *absent*. Absent propagates through + comparisons as `false`, through `has()` as `false`, through `coalesce()` as skipped. It never + throws. +- **Conditions are strict.** An expression used as a condition must evaluate to boolean `true` to be + taken. Absent, `null`, `0`, and `""` are all **false**, and a non-boolean is a *validation* error + where the type is statically knowable. There is no JavaScript truthiness here; the surprise is not + worth the keystrokes. +- **Comparison is JSON-typed.** Number-to-number is numeric; string-to-string is ordinal; anything + cross-type is `false` for ordering operators and `false` for `==`. No coercion ladder. +- **Arithmetic is decimal.** These documents price orders. Binary floating point is the wrong default + and `0.1 + 0.2` is the wrong first impression. Division by zero yields absent. + +### Determinism where routing depends on it + +`BuildAsync` runs **once per attempt**, and a resumed instance must retrace the routing its +checkpoint recorded. So: + +> `$run.now` and any future non-deterministic function are **forbidden in edge conditions and gate +> predicates**, and permitted in templates and projections. + +A non-deterministic condition would let a resumed run take a different branch than the one it +checkpointed — silent, intermittent, and close to undebuggable. The validator rejects it by static +inspection rather than trusting the author to remember. + +--- + +## 5. D4 — The node catalog + +`kind` is the discriminator. Every value maps to an executor the host already ships: + +| `kind` | Executor | Notes | +| --- | --- | --- | +| `transform` | `TransformExecutor` | `set` map of target path → AbEx expression | +| `http` | `ApiCallExecutor` | egress allow-list required | +| `llm` | `LlmExecutor` | model, prompts, structured output, cost | +| `delay` | `DelayExecutor` | durable — checkpoints and halts | +| `approval` | `HumanApprovalExecutor` | approval as an explicit node | +| `publish` | `PublishEventExecutor` | domain event out | +| `wait-event` | `WaitForEventExecutor` | parks until a message matches | +| `fan-in` | `FanInExecutor` | aggregates a barrier's inputs | +| `custom` | a registered `IDslNodeFactory` | **the extension seam** | + +There is **no `delegate` kind**, and there never will be. Arbitrary code is precisely what a document +must not carry. + +### `custom` is the whole scalability story + +```csharp +services.AddDslNode("score-risk", new RiskScoringNodeFactory()); +``` + +```json +{ "id": "score", "kind": "custom", "node": "score-risk", + "with": { "model": "v3", "threshold": 0.82 } } +``` + +The factory receives the `with` object (validated against a schema the factory itself publishes) and +returns a `HostExecutor`. An unregistered `node` name is a **startup** +failure, not a run-time one. + +This is what keeps the DSL from having a ceiling: the answer to "the DSL cannot express this" is +always *ship a node and name it*, never *embed a script*. Engineers extend the vocabulary; authors +compose it. + +--- + +## 6. Document shape + +```json +{ + "dsl": "abacus.workflow/1.0", + "name": "order-settlement", + "version": "1.2.0", + "description": "Prices an order, escalates large ones, settles.", + + "context": { "type": "object", "required": ["orderId"], "properties": { … } }, + "result": { "$ref": "#/$defs/Settlement" }, + + "start": "validate", + "output": ["complete"], + + "nodes": [ + { "id": "validate", "kind": "transform", + "set": { "total": "$ctx.lines[0].unitPrice * $ctx.lines[0].quantity" } }, + + { "id": "settle", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/settlements", + "allowedHosts": ["ledger.internal"], + "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}", + "gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance", "user:cfo"], + "requireApprovers": 2, + "expiresAfter": "PT8H", + "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] }, + "allowModification": true, + "requireSegregationOfDuties": true, + "locked": true + } }, + + { "id": "complete", "kind": "transform", "set": { "status": "'settled'" } } + ], + + "edges": [ + { "from": "validate", "to": "settle", "when": "$.total > 0" }, + { "from": "settle", "to": "complete" } + ], + + "triggers": [ { "topic": "orders.placed", "correlationKey": "$.orderId" } ], + "notifications": { "level": "standard", "stream": true, "emits": ["priced"] }, + "onFailure": [ { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, + "disposition": "retry" } ], + "audit": { "sections": ["submission", "outcome"] }, + "limits": { "maxAttempts": 5 } +} +``` + +Two things about this shape are load-bearing. + +**`dsl` is a versioned media identifier, not decoration.** `abacus.workflow/1.0` selects the schema +and the interpreter. A future `1.1` adds optional fields and stays readable by a `1.0` interpreter; a +`2.0` does not, and the interpreter refuses it by major version rather than failing on a field it +does not recognise. + +**Templates and expressions are different surfaces.** `{{ … }}` inside a string is the existing +`TemplateEngine`, extended to evaluate AbEx and to render `ctx`/`data` roots. A bare string in +`when`, `set` or `correlationKey` is AbEx directly. Mixing the two conventions in one field would be +ambiguous, so no field accepts both. + +--- + +## 7. Validation is two phases, because one is not enough + +**Phase 1 — JSON Schema (Draft 2020-12).** Validates *shape*: required properties, `kind`- +discriminated variants via `if`/`then`, id patterns (`^[a-z][a-z0-9-]{0,63}$`), SemVer, ISO-8601 +durations, topic patterns, enum values. Published at `docs/schema/abacus-workflow-dsl-1.0.json` so an +editor gives completion and inline errors before the document reaches the host. + +**Phase 2 — the semantic validator.** JSON Schema cannot express any of this, and every item is a +real way to write a structurally valid document that is nonsense: + +| Check | Why it is not a schema concern | +| --- | --- | +| Node ids unique | Schema cannot compare array items | +| Every edge endpoint exists | Cross-reference | +| `start` and every `output` name a real node | Cross-reference | +| No unreachable node | Graph traversal | +| No cycle without a `delay` or `wait-event` on it | Graph traversal; a tight loop is a hot spin | +| Every AbEx expression parses, with known functions | Sub-language | +| No non-deterministic function in a condition or predicate | Sub-language + position | +| Gate absent on non-gateable kinds | Cross-field | +| `custom` node names a registered factory | Environment | +| `with` matches the factory's schema | Environment | +| Egress hosts present when the host enforces them | Environment | +| Within `limits` — nodes, edges, expression depth, bytes | Policy | + +Every diagnostic carries a **JSON Pointer**, a stable code, and a severity: + +``` +DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. +DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'? +DSL0631 warn /nodes/7 Node 'notify' is unreachable from 'start'. +``` + +The pointer is not a nicety. A DSL without precise error locations is a DSL people abandon after the +third unhelpful failure, and retrofitting positions into a validator is far harder than building with +them. + +Validation runs at **registration**, so a bad document fails startup — the same place a bad compiled +workflow fails. It also runs on demand at `POST /v2/dsl/validate`, which is what an authoring tool +calls and what makes the DSL usable without a deploy cycle. + +--- + +## 8. Identity, immutability, and drift + +A DSL workflow registers as `(name, version)` exactly like a compiled one, and inherits the +framework's existing rule: **a published version is immutable.** Instances pin their version, and a +document edited under a version its instances are running would rewrite history mid-flight. + +Enforcement is a content hash. The interpreter computes `sha256` over the document's canonical form +(RFC 8785 JCS), records it on the descriptor, and stamps it on every instance. Registering a document +whose `(name, version)` is already known with a different hash is a **startup failure** naming both +hashes. Editing a workflow means bumping the version — which is what the compiled path already +demands, stated in a way a document author will actually encounter. + +The hash also answers the operational question directly: *is this instance running the document I am +looking at?* + +--- + +## 9. Where documents come from + +**In scope for v1:** files on disk and embedded resources, registered at startup. + +```csharp +builder.Services.AddWorkflowHost(config) + .AddDslWorkflow("workflows/order-settlement.json") + .AddDslWorkflowsFromDirectory("workflows/", searchPattern: "*.workflow.json") + .AddDslNode("score-risk", new RiskScoringNodeFactory()); +``` + +**Deliberately out of scope for v1:** a management API that accepts documents at run time. + +That is not caution for its own sake. Today `IWorkflowRegistry` is immutable, built once at startup, +and *everything* leans on it — version resolution, dispatch, the catalog API, tenant gate policy. +Making it mutable is a genuine piece of work touching all of those, plus authorization (who may +publish a workflow?), tenancy (whose workflow is it — and definitions are global while instances are +tenant-scoped), and the migration of in-flight instances. It deserves its own design, not a paragraph +at the end of this one. Phase 6 of the plan names it. + +The file-based path still delivers the actual win: a workflow changes without a code change, and the +document is reviewable in a pull request. + +--- + +## 10. Safety + +A DSL is an untrusted-input surface the moment it is authored by anyone who is not the person who +built the host. v1 loads from disk, but the design assumes it will not stay that way. + +- **No code.** No delegate kind, no scripting, no reflection by name into arbitrary types. Only + registered factories. +- **Bounded evaluation.** Expression depth ≤ 32, no loops or recursion in the grammar, regex match + timeout 200 ms, document ≤ 1 MB, nodes ≤ 500, edges ≤ 2000. All configurable down, none up. +- **Egress unchanged.** `http` nodes go through the same `EgressGuard`. A document cannot widen an + allow-list the host has fixed. +- **Redaction unchanged.** Envelopes traverse the same middleware pipeline, so the same redaction + applies. `ctx` travelling in every message is exactly why this matters — the design deliberately + puts more data in flight, and it must not put more data in logs. +- **Gates cannot be weakened.** `locked` behaves as it does for compiled workflows: tenants may + tighten, never loosen. +- **Failure classification is a whitelist.** `onFailure` matches named framework exceptions and + status ranges; it cannot name arbitrary types. + +--- + +## 11. What this does not attempt + +Stated plainly so review can disagree with the boundary rather than discover it: + +- **Loops and iteration.** No `foreach`. Fan-out over an array is the intended shape, and unbounded + iteration in a checkpointed engine has real semantics to work out. Deferred, not forgotten. +- **Sub-workflows.** The engine supports `workflow.BindAsExecutor(id)`. Composing DSL documents needs + a resolution and versioning story of its own. Phase 6. +- **A surface syntax.** JSON is the interchange format. A YAML front end or a visual editor sits + *above* this and produces these documents; neither belongs in the interpreter. +- **Round-tripping compiled workflows.** A compiled definition cannot be exported as a document. The + DSL is not a serialization of C#; it is a different way in. + +--- + +## 12. Summary + +One envelope type makes the graph uniformly typed. One closed expression language makes conditions +expressible without making them dangerous. One named catalog with a registration seam makes the DSL +extensible without making it a scripting host. Two-phase validation with pointer-accurate diagnostics +makes it usable. A content hash makes it honest about versions. + +Everything else is the runtime that already exists. diff --git a/docs/workflow-dsl-implementation-plan.md b/docs/workflow-dsl-implementation-plan.md new file mode 100644 index 0000000..4561b54 --- /dev/null +++ b/docs/workflow-dsl-implementation-plan.md @@ -0,0 +1,347 @@ +# Workflow DSL — implementation plan + +Realizes [workflow-dsl-design.md](workflow-dsl-design.md). Nothing here changes how a compiled +workflow behaves; the DSL is a second front end onto the runtime that already exists. + +**Status: not started — awaiting design review.** + +| # | Phase | Delivers | Depends on | Status | +| - | ----- | -------- | ---------- | ------ | +| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ⬜ Not started | +| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ⬜ Not started | +| 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ⬜ Not started | +| 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ⬜ Not started | +| 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ⬜ Not started | +| 6 | Deferred | Runtime publication API, sub-workflows, iteration | 5 | ⬜ Out of scope | + +Phases 1–2 are independently testable with no host involved and carry most of the risk. Phase 3 is +mechanical once they land. Phase 4 is small — deliberately, because the design keeps the core change +to a single opt-in interface. + +--- + +## Project layout + +A new project, `src/Abacus.Run.Dsl`, referencing `Abacus.Run` and referenced by the host. + +Keeping it out of `Abacus.Run` is not tidiness. The DSL pulls in a JSON Schema validator and an +expression parser; a host that authors every workflow in C# should not carry either. The existing +architecture boundary tests enforce the layering, and this project sits at the same level as the +adapter projects: `Abstractions ← Core ← {Executors, …} ← Api`, with `Dsl` depending on the public +surface only. + +``` +src/Abacus.Run.Dsl/ + Model/ DslDocument, DslNode, DslEdge, … (the parsed document) + Expressions/ AbExLexer, AbExParser, AbExNode, AbExEvaluator, AbExValidator + Validation/ DslSchemaValidator, DslSemanticValidator, DslDiagnostic + Interpretation/ DslWorkflowDefinition, DslMessage, node factories + Hosting/ AddDslWorkflow, AddDslNode, IDslNodeFactory + Schema/ abacus-workflow-dsl-1.0.json (embedded resource) +``` + +The schema is authored at [docs/schema/abacus-workflow-dsl-1.0.json](schema/abacus-workflow-dsl-1.0.json) +and embedded from there — one copy, so the published schema and the enforced one cannot drift. A test +asserts the embedded resource is byte-identical to the file. + +--- + +## Phase 1 — Envelope and expression core + +No host, no document, no DI. Pure data and a parser, which is what makes this phase cheap to test +exhaustively and worth doing first. + +### 1.1 `DslMessage` + +```csharp +public sealed class DslMessage +{ + public JsonObject Ctx { get; } // frozen at start, copied through unchanged + public JsonNode? Data { get; } // the current value + public DslMeta Meta { get; } // node id, superstep, attempt + + public DslMessage WithData(JsonNode? data); + public static DslMessage Start(JsonElement context); +} +``` + +Immutable, so a message captured by a checkpoint cannot be mutated by a later node. `Ctx` is cloned +once at start and never again — the copy-through is a reference copy, which is what keeps a large +context from being duplicated per node. + +### 1.2 AbEx + +Hand-written recursive-descent lexer and parser producing an immutable AST. No parser generator: the +grammar is a page long, and a hand-written parser is what gives the precise column positions the +diagnostics in Phase 2 depend on. + +- `AbExParser.Parse(string) → AbExResult` — AST or a diagnostic with an offset. Never throws on bad + input; a malformed expression is data. +- `AbExEvaluator.Evaluate(AbExNode, DslMessage, RunMetadata) → AbExValue` — total. Absence is a + value, never an exception. +- `AbExValidator.Analyse(AbExNode) → ExpressionFacts` — unknown functions, arity errors, depth, and + **whether the expression is deterministic**. The determinism flag is what Phase 2's positional rule + reads. + +`AbExValue` is a small struct union over the JSON types plus *absent*. Arithmetic on numbers is +`decimal`. + +**Tests (~120).** Precedence and associativity for every operator. Absence propagation through each +function and operator. Strict boolean coercion — `0`, `""`, `null` and absent are all false. Ordinal +string comparison. Decimal arithmetic including `0.1 + 0.2`. Division by zero → absent. Depth limit. +`matches` timeout. Every parse error carries the right offset. Round-trip: parse → print → parse. + +### 1.3 Template integration + +`TemplateEngine` currently resolves dotted paths through `TemplateBindings`. Extend it with an AbEx +binding source rather than replacing it — `{{ $ctx.orderId }}` and `{{ $.total * 1.2 }}` both work, +and the existing `{{ context.Field }}` form continues to resolve unchanged so no compiled workflow +using a template breaks. + +**Tests (~25).** New forms, old forms, mixed, unterminated placeholder, absent → empty string. + +--- + +## Phase 2 — Document model and validation + +### 2.1 Model + +Records mirroring the schema: `DslDocument`, `DslNode` (a discriminated hierarchy by `kind`), +`DslEdge`, `DslGate`, `DslTrigger`, `DslNotifications`, `DslFailureRule`, `DslAudit`, `DslLimits`. + +Parsing is `System.Text.Json` with a custom converter on `DslNode` reading `kind` first. The parser +records a **JSON Pointer for every node it builds** — the diagnostics are only as good as the +positions, and positions retrofitted into a validator are far more expensive than positions built in. + +### 2.2 Schema validation + +Draft 2020-12 via `JsonSchema.Net`. Structural errors map to `DslDiagnostic` with the pointer the +validator reports. + +The published schema is already exercised: it checks as a legal Draft 2020-12 document, accepts the +design's worked example, and rejects 17 hand-written malformed variants — bad `dsl` version, +uppercase node id, unknown `kind`, `transform` without `set`, `http` without `url`, `conditional` +gate without `when`, `escalate` expiry without assignees, malformed duration and principal, edge +without `to`, `when` on a barrier edge, `select` with one target, an unlisted exception name, an +unknown top-level property, a gate on a `fan-in` node, and a `custom` node without `node`. Those +cases become the Phase 2 fixtures rather than being written again from scratch. + +### 2.3 Semantic validation + +Everything JSON Schema cannot express, from the design's table. Each check gets a stable code: + +| Code | Check | +| --- | --- | +| `DSL0101` | `dsl` major version is supported | +| `DSL0102` | Document hash matches a previously registered `(name, version)` | +| `DSL0201` | Node ids unique | +| `DSL0202` | `start` names a real node | +| `DSL0203` | Every `output` entry names a real node | +| `DSL0207` | Every edge endpoint exists (with a nearest-match suggestion) | +| `DSL0208` | No duplicate unconditional edge unless `idempotent` | +| `DSL0301` | Every node reachable from `start` | +| `DSL0302` | Every non-terminal node has an outgoing edge | +| `DSL0303` | No cycle without a `delay` or `wait-event` on it | +| `DSL0304` | Fan-in barrier sources all reach it | +| `DSL0401` | Every expression parses | +| `DSL0412` | Every function is known, with correct arity | +| `DSL0413` | No non-deterministic function in an edge condition or gate predicate | +| `DSL0414` | Expression depth within limits | +| `DSL0501` | Gate absent on non-gateable kinds | +| `DSL0502` | `conditional` mode has a `when` | +| `DSL0503` | `escalate` expiry names escalation assignees | +| `DSL0601` | `custom` node names a registered factory | +| `DSL0602` | `with` satisfies the factory's schema | +| `DSL0603` | `http` node declares allowed hosts when the host enforces egress | +| `DSL0701` | Document within size, node, and edge limits | + +Codes `DSL06xx` need the host's registrations, so the validator takes an optional +`DslEnvironment` — present at registration and at `POST /v2/dsl/validate`, absent for offline +linting, which then reports those checks as skipped rather than passing. **Silently passing a check +that never ran is worse than not running it**, so the result distinguishes the two. + +```csharp +public sealed record DslDiagnostic( + string Code, DslSeverity Severity, string Pointer, string Message, string? Suggestion); + +public sealed record DslValidationResult( + bool IsValid, + IReadOnlyList Diagnostics, + IReadOnlyList SkippedChecks); +``` + +### 2.4 Canonical hash + +RFC 8785 JCS canonicalization then SHA-256. Used for the immutability rule in §8 of the design. + +**Tests (~150).** One valid-document fixture per node kind. One invalid fixture per diagnostic code, +asserting **code, pointer, and severity** — a validator whose messages are untested drifts into +uselessness. Hash stability across key reordering and whitespace. Skipped-check reporting with no +environment. + +--- + +## Phase 3 — Interpreter + +### 3.1 `DslWorkflowDefinition` + +```csharp +public sealed class DslWorkflowDefinition + : IWorkflowDefinition, + IContextValidatingWorkflow +{ + public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken ct); + public FailureDisposition Classify(WorkflowFailure failure); +} +``` + +Conditionally implements `IAuditedWorkflowDefinition`, `IEventTriggeredWorkflow` and +`INotifyingWorkflow` when the document declares the corresponding block. The registry and runtime +already probe for these with `is`, so a definition that implements one it does not need would declare +an empty policy — hence three thin wrapper types selected at registration rather than one type that +always implements everything. + +`BuildAsync` runs per attempt and must be cheap. The **parsed and validated document is cached at +registration**; a build walks the model and constructs executors, and never re-parses or re-validates. +Expression ASTs are parsed once at registration too, so a build binds already-parsed trees. + +### 3.2 Node factories + +`IDslNodeFactory` with a built-in implementation per `kind`, plus the registration seam: + +```csharp +public interface IDslNodeFactory +{ + string Name { get; } + JsonNode? ParameterSchema { get; } // validated against 'with' at registration + IHostExecutor Create(DslNodeContext context); +} +``` + +`DslNodeContext` carries the node model, the parsed expressions, and the `WorkflowBuildContext` — so +a factory reaches `Services` and `Audit` the same way a compiled definition does. + +Each built-in factory wraps its existing executor in a `DslMessage`-shaped adapter that unwraps +`data`, invokes, and rewraps. The adapters are the only genuinely new execution code in this phase, +and each is a few lines. + +### 3.3 Graph construction + +Walk `edges`, mapping to `AddEdge` / `AddEdge(condition)` / `AddFanOutEdge` / +`AddFanInBarrierEdge`. Conditions close over a pre-parsed AST. `WithOutputFrom` binds the `output` +nodes; `Build(validateOrphans: true)` — the semantic validator has already established reachability, +so this should never fire, and if it does that is a validator bug worth surfacing loudly. + +### 3.4 Failure classification + +Compile `onFailure` into a matcher chain, falling through to `DefaultFailureClassifier.Instance`. + +**Tests (~110 unit, ~30 integration).** Each node kind builds and runs end to end. Conditional +routing, fan-out, fan-in, selector fan-out. A gated node parks and resumes on approval. A +`wait-event` node parks, receives, and resumes. A `delay` node checkpoints and releases its lease. +Failure rules classify. `custom` factory receives its `with`. Envelope `ctx` survives to the last +node. Strict mode rejects a shape violation as dead-stop. + +--- + +## Phase 4 — Host integration + +### 4.1 The one core change + +`IContextValidatingWorkflow` in `Abacus.Run/Abstractions`, consulted by +[`WorkflowRegistry.ValidateContext`](../src/Abacus.Run/Core/WorkflowRegistry.cs) **after** the +existing type bind succeeds. Additive and opt-in: a definition that does not implement it behaves +exactly as today. + +### 4.2 Registration + +```csharp +builder.Services.AddWorkflowHost(config) + .AddDslWorkflow("workflows/order-settlement.json") + .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json") + .AddDslNode("score-risk", new RiskScoringNodeFactory()); +``` + +Each registration parses, validates against the full environment, computes the hash, and registers an +`IWorkflowDefinition`. **A document that fails validation fails startup**, with every diagnostic +written to the log — the same place a bad compiled workflow fails, and for the same reason. + +### 4.3 Endpoints + +| Route | Purpose | +| --- | --- | +| `POST /v2/dsl/validate` | Validate a document without registering it. Returns diagnostics. What an authoring tool calls. | +| `GET /v2/dsl/schema` | The published JSON Schema, for editor completion | +| `GET /v2/dsl/nodes` | The registered node catalog with parameter schemas | +| `GET /v2/workflows/{name}` | Extended with `source: "dsl" \| "compiled"` and, for DSL, `documentHash` | + +`POST /v2/dsl/validate` needs the same authorization as the catalog routes. It reflects the +environment's registered node names back to the caller, which is information about the host — not +secret, but not anonymous either. + +**Tests (~40 integration).** Startup fails on an invalid document, with diagnostics logged. Startup +fails on a hash conflict for an existing `(name, version)`. A DSL workflow appears in the catalog and +starts through the normal route. Context schema violations return 400 with field errors. The validate +endpoint returns pointer-accurate diagnostics. Schema endpoint matches the file on disk. + +--- + +## Phase 5 — Documentation and example + +- A new wiki chapter, **Authoring with the DSL**, placed beside *Authoring a workflow*, with the same + structure — document shape, node reference, expression reference, validation, limits — and a + parallel appendix mapping each existing A.1–A.10 variation to its DSL equivalent. The compiled and + DSL paths should be legible side by side, because the honest reason to pick one over the other is + what a reader most needs. +- README: a short section, and the DSL named in the feature list. +- `src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json` — the existing + `ExampleOrderWorkflow` expressed as a document, registered alongside the compiled one under a + different name. A test asserts both produce the same result for the same context, which is the + clearest possible statement that the DSL is a front end and not a fork. +- The design doc's §11 boundaries restated in the wiki, so a reader hits the limits in the docs rather + than in an error message. + +--- + +## Phase 6 — Deferred, and why + +Named rather than silently omitted; each is a design of its own. + +**Runtime publication API.** `IWorkflowRegistry` is immutable and built once at startup, and version +resolution, dispatch, the catalog and tenant gate policy all lean on that. Making it mutable also +raises authorization (who may publish?), tenancy (definitions are global while instances are +tenant-scoped), and in-flight instance migration. The file-based path already delivers the core win — +a workflow changes without a code change — so this is a genuine next step, not a missing piece. + +**Sub-workflows.** The engine supports `workflow.BindAsExecutor(id)`. Composing documents needs +resolution, version pinning, and cycle detection across documents. + +**Iteration.** Unbounded loops in a checkpointed engine have real semantics to establish — the +checkpoint's size, the superstep count, and what a retry means mid-iteration. Fan-out over an array +covers the common case in v1. + +--- + +## Risk + +| Risk | Mitigation | +| --- | --- | +| The expression language grows into a scripting host | The function set is closed and small; extension goes through `custom` nodes, not new syntax. Adding a function is a deliberate change to a documented list. | +| Diagnostics are unhelpful and the DSL is abandoned | Pointer accuracy is a tested requirement from Phase 2, not a polish item. Every diagnostic code has a test asserting its pointer. | +| The envelope's `ctx` inflates checkpoints | `Ctx` is a reference copy, cloned once at start. Measured in Phase 3 with a large-context fixture. | +| Redaction gaps — more data is in flight per message | DSL nodes traverse the same middleware pipeline. An integration test asserts a redacted field in `ctx` stays redacted at the last node. | +| Schema drift between published and embedded | One file, embedded from `docs/schema/`; a test asserts byte equality. | +| The DSL looks like it can do anything and cannot | §11 of the design and the wiki chapter both state the boundary. `custom` is presented as the answer, not as an escape hatch. | + +--- + +## Verification summary + +| Suite | Added | Covers | +| --- | --- | --- | +| Unit | ~405 | AbEx, model, validation, interpreter, factories | +| Integration | ~70 | Startup, catalog, endpoints, end-to-end runs, redaction, parity with the compiled example | +| Architecture | 3 | `Abacus.Run.Dsl` depends only on the public surface; no host reference; schema resource matches the file | + +`dotnet build Abacus.Run.slnx` then `dotnet test`, with the existing suites unchanged — the +`IContextValidatingWorkflow` hook is the only core edit, and nothing implements it today. From 652dac96af17c0b4dd6c23598425633989925c81 Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 21:13:19 +0100 Subject: [PATCH 2/8] docs(dsl): move design and plan into the numbered implementation series Slots the DSL artifacts in alongside the existing docs/implementation sequence as 06 and 07, and repoints the relative links the move broke. The schema stays at docs/schema/. It is a published product artifact - embedded as a resource, served from GET /v2/dsl/schema, and consumed by editors - not an implementation note, so it does not belong in the series. --- .../06-workflow-dsl-design.md} | 0 .../07-workflow-dsl-implementation-plan.md} | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/{workflow-dsl-design.md => implementation/06-workflow-dsl-design.md} (100%) rename docs/{workflow-dsl-implementation-plan.md => implementation/07-workflow-dsl-implementation-plan.md} (98%) diff --git a/docs/workflow-dsl-design.md b/docs/implementation/06-workflow-dsl-design.md similarity index 100% rename from docs/workflow-dsl-design.md rename to docs/implementation/06-workflow-dsl-design.md diff --git a/docs/workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md similarity index 98% rename from docs/workflow-dsl-implementation-plan.md rename to docs/implementation/07-workflow-dsl-implementation-plan.md index 4561b54..ecf7c81 100644 --- a/docs/workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -1,6 +1,6 @@ # Workflow DSL — implementation plan -Realizes [workflow-dsl-design.md](workflow-dsl-design.md). Nothing here changes how a compiled +Realizes [06-workflow-dsl-design.md](06-workflow-dsl-design.md). Nothing here changes how a compiled workflow behaves; the DSL is a second front end onto the runtime that already exists. **Status: not started — awaiting design review.** @@ -40,7 +40,7 @@ src/Abacus.Run.Dsl/ Schema/ abacus-workflow-dsl-1.0.json (embedded resource) ``` -The schema is authored at [docs/schema/abacus-workflow-dsl-1.0.json](schema/abacus-workflow-dsl-1.0.json) +The schema is authored at [docs/schema/abacus-workflow-dsl-1.0.json](../schema/abacus-workflow-dsl-1.0.json) and embedded from there — one copy, so the published schema and the enforced one cannot drift. A test asserts the embedded resource is byte-identical to the file. @@ -249,7 +249,7 @@ node. Strict mode rejects a shape violation as dead-stop. ### 4.1 The one core change `IContextValidatingWorkflow` in `Abacus.Run/Abstractions`, consulted by -[`WorkflowRegistry.ValidateContext`](../src/Abacus.Run/Core/WorkflowRegistry.cs) **after** the +[`WorkflowRegistry.ValidateContext`](../../src/Abacus.Run/Core/WorkflowRegistry.cs) **after** the existing type bind succeeds. Additive and opt-in: a definition that does not implement it behaves exactly as today. From 459cf9c16830d5dff5753f2e262a1642dfdc5504 Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 21:26:41 +0100 Subject: [PATCH 3/8] feat(dsl): phase 1 - envelope and expression core Adds Abacus.Run.Dsl with the two pieces the interpreter is built on, both testable without a host. DslMessage is the single envelope every DSL node sends and receives. It carries the frozen start context alongside the current value, which is what lets an expression eleven nodes deep still read $ctx - a compiled node closes over C# scope, and a document has none. Immutable, so a message a checkpoint captured cannot be mutated by a node that runs later. AbEx is the expression language: a hand-written lexer and recursive-descent parser producing an immutable AST, a total evaluator, and static analysis. Total is the load-bearing property. Absence is a value, not an exception, so no expression over any document shape can throw - a workflow must take the other branch, not fail. Comparisons involving an absent operand are false including !=, because a document asking whether a field it never set differs from a value must not be told yes; has() is how presence is asked about. Conditions are strictly boolean, with no truthiness ladder. Arithmetic is decimal, because these documents price orders. The function set is closed. An unknown name is a validation error with a nearest-match suggestion, and the pattern argument to matches() must be a string literal so every regex in a document is reviewable by reading the document. Regex matching carries a 200ms timeout; a timed-out match is a non-match rather than a way for an author to stall a dispatcher. Two deviations from the grammar as written, both recorded in the plan: unary ! and - bind tightest rather than sitting between && and comparison, and bare-identifier path roots are dropped in favour of requiring $, $ctx or $run. The first matches what an author expects; the second removes a real ambiguity between a path and a function name. One additive change to Abacus.Run: ITemplateBindingSource, which lets a message resolve its own {{ }} placeholders. The default dotted-path walk suits one POCO root and cannot address an envelope carrying two objects. Opt-in - a type that does not implement it resolves exactly as before. 207 tests. --- Abacus.Run.slnx | 2 + .../07-workflow-dsl-implementation-plan.md | 4 +- src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj | 44 ++ .../Expressions/AbExEvaluator.cs | 344 +++++++++++++++ .../Expressions/AbExFunctions.cs | 103 +++++ src/Abacus.Run.Dsl/Expressions/AbExLexer.cs | 218 ++++++++++ src/Abacus.Run.Dsl/Expressions/AbExNode.cs | 72 ++++ src/Abacus.Run.Dsl/Expressions/AbExParser.cs | 406 ++++++++++++++++++ .../Expressions/AbExValidator.cs | 126 ++++++ src/Abacus.Run.Dsl/Expressions/AbExValue.cs | 163 +++++++ .../Interpretation/DslMessage.cs | 120 ++++++ src/Abacus.Run/Executors/TemplateEngine.cs | 26 ++ .../Abacus.Run.DslTests/AbExEvaluatorTests.cs | 346 +++++++++++++++ tests/Abacus.Run.DslTests/AbExParserTests.cs | 273 ++++++++++++ .../Abacus.Run.DslTests/AbExValidatorTests.cs | 128 ++++++ .../Abacus.Run.DslTests.csproj | 19 + tests/Abacus.Run.DslTests/DslMessageTests.cs | 155 +++++++ 17 files changed, 2547 insertions(+), 2 deletions(-) create mode 100644 src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExLexer.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExNode.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExParser.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExValidator.cs create mode 100644 src/Abacus.Run.Dsl/Expressions/AbExValue.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/DslMessage.cs create mode 100644 tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs create mode 100644 tests/Abacus.Run.DslTests/AbExParserTests.cs create mode 100644 tests/Abacus.Run.DslTests/AbExValidatorTests.cs create mode 100644 tests/Abacus.Run.DslTests/Abacus.Run.DslTests.csproj create mode 100644 tests/Abacus.Run.DslTests/DslMessageTests.cs diff --git a/Abacus.Run.slnx b/Abacus.Run.slnx index 01ac204..56ed2ec 100644 --- a/Abacus.Run.slnx +++ b/Abacus.Run.slnx @@ -3,11 +3,13 @@ + + diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md index ecf7c81..ffd9980 100644 --- a/docs/implementation/07-workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -3,11 +3,11 @@ Realizes [06-workflow-dsl-design.md](06-workflow-dsl-design.md). Nothing here changes how a compiled workflow behaves; the DSL is a second front end onto the runtime that already exists. -**Status: not started — awaiting design review.** +**Status: in progress.** | # | Phase | Delivers | Depends on | Status | | - | ----- | -------- | ---------- | ------ | -| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ⬜ Not started | +| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ✅ Done — 207 tests | | 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ⬜ Not started | | 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ⬜ Not started | | 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ⬜ Not started | diff --git a/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj new file mode 100644 index 0000000..47c771a --- /dev/null +++ b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj @@ -0,0 +1,44 @@ + + + Abacus.Run.Dsl + + + + true + Abacus.Run.Dsl + 1.0.0 + Abacus Run DSL - declarative workflow authoring + Najaf Shaikh + CodeShayk + Abacus Run + + Authors an Abacus Run workflow as a JSON document instead of C#. Validates against a published + JSON Schema, reports pointer-accurate diagnostics, and interprets the document onto the same + runtime a compiled definition uses. Separate from Abacus.Run so a host that authors every + workflow in code carries neither the schema validator nor the expression parser. + + workflow;dsl;json-schema;declarative;orchestration;dotnet + MIT + https://github.com/CodeShayk/Abacus-Run + https://github.com/CodeShayk/Abacus-Run + git + false + Copyright (c) 2025 Najaf Shaikh + true + + + + + + + + + + + + + + + diff --git a/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs b/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs new file mode 100644 index 0000000..f1d62bb --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExEvaluator.cs @@ -0,0 +1,344 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace Abacus.Run.Dsl.Expressions; + +/// The three roots an expression may read. Nothing else is in scope. +public sealed record AbExContext(JsonNode? Data, JsonNode? Context, JsonObject Run) +{ + public static AbExContext Empty { get; } = new(null, null, []); +} + +/// +/// Evaluates an AbEx tree. +/// +/// +/// +/// Total by construction: every operation over every value produces a value, and +/// is what stands in for "no answer". Nothing here throws, because a +/// workflow must not fail on an expression over a document shape the author did not anticipate — it +/// must take the other branch. +/// +/// +/// Pure by construction: there is no I/O, no state, and no way to reach either from the grammar. +/// +/// +public static class AbExEvaluator +{ + /// Bounds a pathological pattern. A timed-out match is a non-match, never a fault. + public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200); + + private static readonly ConcurrentDictionary RegexCache = new(StringComparer.Ordinal); + + public static AbExValue Evaluate(AbExNode node, AbExContext context) + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(context); + + return node switch + { + AbExLiteral literal => literal.Value, + AbExPath path => ResolvePath(path, context), + AbExUnary unary => EvaluateUnary(unary, context), + AbExBinary binary => EvaluateBinary(binary, context), + AbExCall call => EvaluateCall(call, context), + _ => AbExValue.Absent + }; + } + + /// + /// Strict boolean coercion: only true is true. Absent, null, 0 and "" are + /// all false, and there is no truthiness ladder to remember. + /// + public static bool EvaluateCondition(AbExNode node, AbExContext context) + => Evaluate(node, context).IsTruthy; + + private static AbExValue ResolvePath(AbExPath path, AbExContext context) + { + JsonNode? current = path.Root switch + { + AbExRoot.Data => context.Data, + AbExRoot.Context => context.Context, + _ => context.Run + }; + + // A root that is itself missing is absent, not null: '$ .x' over a null data payload has no + // answer, and reporting null would let '== null' succeed against a value that is not there. + if (current is null && path.Segments.Count > 0) + { + return AbExValue.Absent; + } + + foreach (AbExSegment segment in path.Segments) + { + if (segment.IsIndex) + { + if (current is not JsonArray array || segment.Index < 0 || segment.Index >= array.Count) + { + return AbExValue.Absent; + } + + current = array[segment.Index]; + continue; + } + + if (current is not JsonObject obj || !obj.TryGetPropertyValue(segment.Name!, out JsonNode? child)) + { + return AbExValue.Absent; + } + + current = child; + } + + return path.Segments.Count == 0 && current is null + ? AbExValue.Absent + : AbExValue.FromNode(current); + } + + private static AbExValue EvaluateUnary(AbExUnary unary, AbExContext context) + { + AbExValue operand = Evaluate(unary.Operand, context); + + return unary.Operator switch + { + "!" => operand.Kind == AbExValueKind.Boolean + ? AbExValue.Bool(!operand.AsBoolean) + : AbExValue.Absent, + + "-" => operand.Kind == AbExValueKind.Number + ? AbExValue.Number(-operand.AsNumber) + : AbExValue.Absent, + + _ => AbExValue.Absent + }; + } + + private static AbExValue EvaluateBinary(AbExBinary binary, AbExContext context) + { + // Short-circuit before evaluating the right operand, so a guard like + // 'has($.order) && $.order.total > 0' costs nothing when the guard fails. + if (binary.Operator is "&&" or "||") + { + AbExValue left = Evaluate(binary.Left, context); + if (left.Kind != AbExValueKind.Boolean) + { + return AbExValue.Absent; + } + + if (binary.Operator == "&&" && !left.AsBoolean) return AbExValue.False; + if (binary.Operator == "||" && left.AsBoolean) return AbExValue.True; + + AbExValue right = Evaluate(binary.Right, context); + return right.Kind == AbExValueKind.Boolean ? right : AbExValue.Absent; + } + + AbExValue a = Evaluate(binary.Left, context); + AbExValue b = Evaluate(binary.Right, context); + + return binary.Operator switch + { + "==" or "!=" or "<" or "<=" or ">" or ">=" => Compare(binary.Operator, a, b), + _ => Arithmetic(binary.Operator, a, b) + }; + } + + /// + /// Absence makes every comparison false, including !=. That is deliberate: a document + /// asking whether a field it never set differs from a value should not be told "yes". Use + /// has() to ask about presence. + /// + private static AbExValue Compare(string op, AbExValue a, AbExValue b) + { + if (a.IsAbsent || b.IsAbsent) + { + return AbExValue.False; + } + + if (op is "==" or "!=") + { + bool equal = a.Equals(b); + return AbExValue.Bool(op == "==" ? equal : !equal); + } + + int comparison; + if (a.Kind == AbExValueKind.Number && b.Kind == AbExValueKind.Number) + { + comparison = decimal.Compare(a.AsNumber, b.AsNumber); + } + else if (a.Kind == AbExValueKind.String && b.Kind == AbExValueKind.String) + { + comparison = string.CompareOrdinal(a.AsString, b.AsString); + } + else + { + // No coercion ladder: ordering two different JSON types has no defensible answer. + return AbExValue.False; + } + + return AbExValue.Bool(op switch + { + "<" => comparison < 0, + "<=" => comparison <= 0, + ">" => comparison > 0, + _ => comparison >= 0 + }); + } + + /// + /// Decimal, and numbers only. These documents price orders, so binary floating point is the + /// wrong default. + does not concatenate strings — that is what templates are for, and a + /// + that sometimes adds and sometimes joins is the single most reliable source of bugs + /// in languages that allow it. + /// + private static AbExValue Arithmetic(string op, AbExValue a, AbExValue b) + { + if (a.Kind != AbExValueKind.Number || b.Kind != AbExValueKind.Number) + { + return AbExValue.Absent; + } + + decimal x = a.AsNumber; + decimal y = b.AsNumber; + + switch (op) + { + case "+": return AbExValue.Number(x + y); + case "-": return AbExValue.Number(x - y); + case "*": return AbExValue.Number(x * y); + + case "/": + case "%": + if (y == 0m) + { + return AbExValue.Absent; + } + + return AbExValue.Number(op == "/" ? x / y : x % y); + + default: + return AbExValue.Absent; + } + } + + private static AbExValue EvaluateCall(AbExCall call, AbExContext context) + { + // An unknown or mis-arity call cannot reach here through a validated document; if it does, + // absent keeps the evaluator total rather than surfacing a validator bug as a run failure. + if (!AbExFunctions.TryGet(call.Name, out AbExFunction function) || + !function.AcceptsArity(call.Arguments.Count)) + { + return AbExValue.Absent; + } + + switch (call.Name) + { + case "has": + return AbExValue.Bool(!Evaluate(call.Arguments[0], context).IsAbsent); + + case "coalesce": + foreach (AbExNode argument in call.Arguments) + { + AbExValue candidate = Evaluate(argument, context); + if (candidate.Kind is not (AbExValueKind.Absent or AbExValueKind.Null)) + { + return candidate; + } + } + + return AbExValue.Absent; + } + + AbExValue first = Evaluate(call.Arguments[0], context); + + switch (call.Name) + { + case "len": + return AbExValue.Number(first.Length); + + case "lower": + return first.Kind == AbExValueKind.String + ? AbExValue.String(first.AsString.ToLowerInvariant()) + : AbExValue.Absent; + + case "upper": + return first.Kind == AbExValueKind.String + ? AbExValue.String(first.AsString.ToUpperInvariant()) + : AbExValue.Absent; + + case "number": + return first.Kind switch + { + AbExValueKind.Number => first, + AbExValueKind.String when decimal.TryParse( + first.AsString, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsed) + => AbExValue.Number(parsed), + _ => AbExValue.Absent + }; + + case "string": + return first.IsAbsent ? AbExValue.Absent : AbExValue.String(first.ToText()); + + case "bool": + return first.Kind switch + { + AbExValueKind.Boolean => first, + AbExValueKind.String when bool.TryParse(first.AsString, out bool parsed) + => AbExValue.Bool(parsed), + _ => AbExValue.Absent + }; + } + + AbExValue second = Evaluate(call.Arguments[1], context); + + if (first.Kind != AbExValueKind.String || second.Kind != AbExValueKind.String) + { + return AbExValue.Absent; + } + + string subject = first.AsString; + string operand = second.AsString; + + return call.Name switch + { + "contains" => AbExValue.Bool(subject.Contains(operand, StringComparison.Ordinal)), + "startsWith" => AbExValue.Bool(subject.StartsWith(operand, StringComparison.Ordinal)), + "endsWith" => AbExValue.Bool(subject.EndsWith(operand, StringComparison.Ordinal)), + "matches" => Matches(subject, operand), + _ => AbExValue.Absent + }; + } + + private static AbExValue Matches(string subject, string pattern) + { + Regex? regex = RegexCache.GetOrAdd(pattern, static p => + { + try + { + return new Regex(p, RegexOptions.CultureInvariant, RegexTimeout); + } + catch (ArgumentException) + { + // Cached as null so a bad pattern is compiled once, not once per evaluation. + return null; + } + }); + + if (regex is null) + { + return AbExValue.Absent; + } + + try + { + return AbExValue.Bool(regex.IsMatch(subject)); + } + catch (RegexMatchTimeoutException) + { + // A pattern that cannot decide in 200 ms has not matched. Failing the run instead would + // hand any author a way to stall a dispatcher. + return AbExValue.False; + } + } +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs b/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs new file mode 100644 index 0000000..b64fb65 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExFunctions.cs @@ -0,0 +1,103 @@ +namespace Abacus.Run.Dsl.Expressions; + +/// Arity and evaluation rules for one built-in function. +public sealed record AbExFunction(string Name, int MinArguments, int MaxArguments, string Summary) +{ + /// Unbounded arity, used by coalesce. + public const int Variadic = int.MaxValue; + + public bool AcceptsArity(int count) => count >= MinArguments && count <= MaxArguments; + + public string DescribeArity() => MaxArguments switch + { + Variadic => $"at least {MinArguments}", + _ when MinArguments == MaxArguments => MinArguments.ToString(), + _ => $"{MinArguments} to {MaxArguments}" + }; +} + +/// +/// The closed function set. Closed is the point: an unknown name is a validation error the author +/// sees while editing, and growing this list is a deliberate, reviewable change rather than +/// something a document can do to itself. +/// +public static class AbExFunctions +{ + private static readonly Dictionary Registry = + new(StringComparer.Ordinal) + { + ["len"] = new("len", 1, 1, "Length of a string, array or object; 0 for anything else."), + ["has"] = new("has", 1, 1, "Whether the argument resolved to anything at all."), + ["lower"] = new("lower", 1, 1, "Lowercases a string, invariant culture."), + ["upper"] = new("upper", 1, 1, "Uppercases a string, invariant culture."), + ["contains"] = new("contains", 2, 2, "Ordinal substring test."), + ["startsWith"] = new("startsWith", 2, 2, "Ordinal prefix test."), + ["endsWith"] = new("endsWith", 2, 2, "Ordinal suffix test."), + ["matches"] = new("matches", 2, 2, "Regex test. The pattern must be a string literal."), + ["coalesce"] = new("coalesce", 1, AbExFunction.Variadic, "First argument that is neither absent nor null."), + ["number"] = new("number", 1, 1, "Coerces to a number, or absent if it cannot."), + ["string"] = new("string", 1, 1, "Coerces to a string."), + ["bool"] = new("bool", 1, 1, "Coerces to a boolean, or absent if it cannot.") + }; + + public static IReadOnlyCollection Names => Registry.Keys; + + public static bool TryGet(string name, out AbExFunction function) => Registry.TryGetValue(name, out function!); + + public static bool Exists(string name) => Registry.ContainsKey(name); + + /// + /// Nearest known name by edit distance, for "did you mean". Only offered when the candidate is + /// close enough that the suggestion is likely right rather than merely the least-wrong entry. + /// + public static string? Suggest(string name) + { + string? best = null; + int bestDistance = int.MaxValue; + + foreach (string candidate in Registry.Keys) + { + int distance = EditDistance(name, candidate); + if (distance < bestDistance) + { + bestDistance = distance; + best = candidate; + } + } + + int threshold = Math.Max(2, name.Length / 3); + return bestDistance <= threshold ? best : null; + } + + /// + /// Case-insensitive Levenshtein distance. Public because the semantic validator suggests + /// nearest node ids the same way this suggests nearest function names. + /// + public static int EditDistance(string a, string b) + { + if (a.Length == 0) return b.Length; + if (b.Length == 0) return a.Length; + + int[] previous = new int[b.Length + 1]; + int[] current = new int[b.Length + 1]; + + for (int j = 0; j <= b.Length; j++) + { + previous[j] = j; + } + + for (int i = 1; i <= a.Length; i++) + { + current[0] = i; + for (int j = 1; j <= b.Length; j++) + { + int cost = char.ToLowerInvariant(a[i - 1]) == char.ToLowerInvariant(b[j - 1]) ? 0 : 1; + current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost); + } + + (previous, current) = (current, previous); + } + + return previous[b.Length]; + } +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs b/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs new file mode 100644 index 0000000..0be2ea4 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExLexer.cs @@ -0,0 +1,218 @@ +using System.Globalization; +using System.Text; + +namespace Abacus.Run.Dsl.Expressions; + +internal enum AbExTokenKind +{ + Root, // $ | $ctx | $run + Identifier, + Number, + String, + True, + False, + NullLiteral, + Dot, + LBracket, + RBracket, + LParen, + RParen, + Comma, + Operator, + End, + Invalid +} + +internal readonly record struct AbExToken(AbExTokenKind Kind, string Text, int Offset) +{ + public override string ToString() => Kind == AbExTokenKind.End ? "end of expression" : $"'{Text}'"; +} + +/// +/// Hand-written lexer. Every token carries its source offset, because the diagnostics the DSL lives +/// or dies by are only as precise as the positions they are built from. +/// +internal sealed class AbExLexer +{ + private readonly string _source; + private int _index; + + internal AbExLexer(string source) => _source = source; + + /// Set when a token comes back . + internal string? Error { get; private set; } + + internal AbExToken Next() + { + SkipWhitespace(); + + if (_index >= _source.Length) + { + return new AbExToken(AbExTokenKind.End, string.Empty, _index); + } + + int start = _index; + char c = _source[_index]; + + if (c == '$') + { + _index++; + while (_index < _source.Length && (char.IsLetterOrDigit(_source[_index]) || _source[_index] == '_')) + { + _index++; + } + + return new AbExToken(AbExTokenKind.Root, _source[start.._index], start); + } + + if (char.IsLetter(c) || c == '_') + { + while (_index < _source.Length && (char.IsLetterOrDigit(_source[_index]) || _source[_index] == '_')) + { + _index++; + } + + string word = _source[start.._index]; + return word switch + { + "true" => new AbExToken(AbExTokenKind.True, word, start), + "false" => new AbExToken(AbExTokenKind.False, word, start), + "null" => new AbExToken(AbExTokenKind.NullLiteral, word, start), + _ => new AbExToken(AbExTokenKind.Identifier, word, start) + }; + } + + if (char.IsDigit(c)) + { + return ReadNumber(start); + } + + if (c is '\'' or '"') + { + return ReadString(start, c); + } + + return ReadPunctuation(start, c); + } + + private void SkipWhitespace() + { + while (_index < _source.Length && char.IsWhiteSpace(_source[_index])) + { + _index++; + } + } + + private AbExToken ReadNumber(int start) + { + while (_index < _source.Length && char.IsDigit(_source[_index])) + { + _index++; + } + + if (_index < _source.Length && _source[_index] == '.' && + _index + 1 < _source.Length && char.IsDigit(_source[_index + 1])) + { + _index++; + while (_index < _source.Length && char.IsDigit(_source[_index])) + { + _index++; + } + } + + string text = _source[start.._index]; + + // Rejected here rather than at evaluation: a literal too large for decimal is a mistake in + // the document, and the author should hear about it while editing it. + if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _)) + { + Error = $"Number '{text}' is out of range."; + return new AbExToken(AbExTokenKind.Invalid, text, start); + } + + return new AbExToken(AbExTokenKind.Number, text, start); + } + + private AbExToken ReadString(int start, char quote) + { + _index++; // opening quote + var builder = new StringBuilder(); + + while (_index < _source.Length) + { + char c = _source[_index]; + + if (c == '\\') + { + if (_index + 1 >= _source.Length) + { + break; + } + + char escaped = _source[_index + 1]; + builder.Append(escaped switch + { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + '\\' => '\\', + '\'' => '\'', + '"' => '"', + _ => escaped + }); + _index += 2; + continue; + } + + if (c == quote) + { + _index++; + return new AbExToken(AbExTokenKind.String, builder.ToString(), start); + } + + builder.Append(c); + _index++; + } + + Error = "Unterminated string literal."; + return new AbExToken(AbExTokenKind.Invalid, _source[start..], start); + } + + private AbExToken ReadPunctuation(int start, char c) + { + switch (c) + { + case '.': _index++; return new AbExToken(AbExTokenKind.Dot, ".", start); + case '[': _index++; return new AbExToken(AbExTokenKind.LBracket, "[", start); + case ']': _index++; return new AbExToken(AbExTokenKind.RBracket, "]", start); + case '(': _index++; return new AbExToken(AbExTokenKind.LParen, "(", start); + case ')': _index++; return new AbExToken(AbExTokenKind.RParen, ")", start); + case ',': _index++; return new AbExToken(AbExTokenKind.Comma, ",", start); + } + + // Two-character operators first, so '==' never lexes as two '=' tokens. + if (_index + 1 < _source.Length) + { + string pair = _source.Substring(_index, 2); + if (pair is "==" or "!=" or "<=" or ">=" or "&&" or "||") + { + _index += 2; + return new AbExToken(AbExTokenKind.Operator, pair, start); + } + } + + if (c is '<' or '>' or '!' or '+' or '-' or '*' or '/' or '%') + { + _index++; + return new AbExToken(AbExTokenKind.Operator, c.ToString(), start); + } + + // A bare '=' is the classic slip for '=='; naming it is worth more than "unexpected character". + Error = c == '=' + ? "'=' is not an operator. Use '==' to compare." + : $"Unexpected character '{c}'."; + + _index++; + return new AbExToken(AbExTokenKind.Invalid, c.ToString(), start); + } +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExNode.cs b/src/Abacus.Run.Dsl/Expressions/AbExNode.cs new file mode 100644 index 0000000..c0a5976 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExNode.cs @@ -0,0 +1,72 @@ +namespace Abacus.Run.Dsl.Expressions; + +/// Which object a path is rooted in. +public enum AbExRoot +{ + /// $ — the current message's data. + Data, + + /// $ctx — the frozen start context, reachable from every node. + Context, + + /// $run — instance, tenant, attempt, superstep, workflow, version, now. + Run +} + +/// One step of a path: a property name or an array index. +public readonly record struct AbExSegment(string? Name, int Index) +{ + public bool IsIndex => Name is null; + + public static AbExSegment Property(string name) => new(name, -1); + public static AbExSegment At(int index) => new(null, index); + + public override string ToString() => IsIndex ? $"[{Index}]" : $".{Name}"; +} + +/// +/// An immutable AbEx syntax tree. Parsed once at registration and evaluated many times, so nothing +/// here holds evaluation state. +/// +public abstract record AbExNode +{ + /// Nesting depth, used to enforce the configured limit at validation time. + public abstract int Depth { get; } + + /// Source offset of the construct's first token, for diagnostics. + public int Offset { get; init; } +} + +public sealed record AbExLiteral(AbExValue Value) : AbExNode +{ + public override int Depth => 1; +} + +public sealed record AbExPath(AbExRoot Root, IReadOnlyList Segments) : AbExNode +{ + public override int Depth => 1; + + /// True for $run.now, the one value that differs between two evaluations. + public bool IsNonDeterministic => + Root == AbExRoot.Run && Segments.Count > 0 && + string.Equals(Segments[0].Name, "now", StringComparison.Ordinal); + + public override string ToString() => + (Root switch { AbExRoot.Data => "$", AbExRoot.Context => "$ctx", _ => "$run" }) + + string.Concat(Segments); +} + +public sealed record AbExUnary(string Operator, AbExNode Operand) : AbExNode +{ + public override int Depth => Operand.Depth + 1; +} + +public sealed record AbExBinary(string Operator, AbExNode Left, AbExNode Right) : AbExNode +{ + public override int Depth => Math.Max(Left.Depth, Right.Depth) + 1; +} + +public sealed record AbExCall(string Name, IReadOnlyList Arguments) : AbExNode +{ + public override int Depth => Arguments.Count == 0 ? 1 : Arguments.Max(a => a.Depth) + 1; +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExParser.cs b/src/Abacus.Run.Dsl/Expressions/AbExParser.cs new file mode 100644 index 0000000..f381c83 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExParser.cs @@ -0,0 +1,406 @@ +using System.Globalization; + +namespace Abacus.Run.Dsl.Expressions; + +/// Where a parse failed, and why. +public sealed record AbExError(string Message, int Offset) +{ + public override string ToString() => $"{Message} (at offset {Offset})"; +} + +/// An AST or an error. Never both, and never an exception. +public sealed record AbExResult(AbExNode? Node, AbExError? Error) +{ + public bool IsSuccess => Node is not null; + + public static AbExResult Ok(AbExNode node) => new(node, null); + public static AbExResult Fail(string message, int offset) => new(null, new AbExError(message, offset)); +} + +/// +/// Recursive-descent parser for AbEx. +/// +/// +/// +/// Hand-written rather than generated: the grammar is a page long, and a hand-written parser is what +/// gives every construct the exact source offset the diagnostics depend on. +/// +/// +/// Parsing never throws. A malformed expression is data — it arrives in a document from outside the +/// build, and the only useful response is a diagnostic pointing at it. +/// +/// +public static class AbExParser +{ + /// Guards against a pathological document; the semantic validator enforces its own limit too. + public const int MaxDepth = 32; + + public static AbExResult Parse(string? expression) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return AbExResult.Fail("An expression is required.", 0); + } + + var parser = new Parser(expression); + return parser.ParseAll(); + } + + /// Parses or throws. For framework-internal call sites that have already validated. + public static AbExNode ParseOrThrow(string expression) + { + AbExResult result = Parse(expression); + return result.Node ?? throw new FormatException( + $"Invalid AbEx expression '{expression}'. {result.Error!.Message}"); + } + + private sealed class Parser + { + private readonly string _source; + private readonly AbExLexer _lexer; + private AbExToken _current; + private AbExError? _error; + + internal Parser(string source) + { + _source = source; + _lexer = new AbExLexer(source); + _current = _lexer.Next(); + CaptureLexError(); + } + + internal AbExResult ParseAll() + { + if (_error is not null) + { + return new AbExResult(null, _error); + } + + AbExNode? node = ParseOr(); + + if (_error is not null) + { + return new AbExResult(null, _error); + } + + if (_current.Kind != AbExTokenKind.End) + { + return AbExResult.Fail( + $"Unexpected {_current} after a complete expression.", _current.Offset); + } + + if (node!.Depth > MaxDepth) + { + return AbExResult.Fail( + $"Expression nests {node.Depth} deep; the limit is {MaxDepth}.", 0); + } + + return AbExResult.Ok(node); + } + + private void Advance() + { + _current = _lexer.Next(); + CaptureLexError(); + } + + private void CaptureLexError() + { + if (_current.Kind == AbExTokenKind.Invalid && _error is null) + { + _error = new AbExError(_lexer.Error ?? "Invalid token.", _current.Offset); + } + } + + private void Fail(string message, int offset) => _error ??= new AbExError(message, offset); + + private bool IsOperator(params string[] operators) => + _current.Kind == AbExTokenKind.Operator && Array.IndexOf(operators, _current.Text) >= 0; + + private AbExNode? ParseOr() + { + AbExNode? left = ParseAnd(); + while (_error is null && IsOperator("||")) + { + int offset = _current.Offset; + Advance(); + AbExNode? right = ParseAnd(); + if (_error is not null) return null; + left = new AbExBinary("||", left!, right!) { Offset = offset }; + } + + return left; + } + + private AbExNode? ParseAnd() + { + AbExNode? left = ParseComparison(); + while (_error is null && IsOperator("&&")) + { + int offset = _current.Offset; + Advance(); + AbExNode? right = ParseComparison(); + if (_error is not null) return null; + left = new AbExBinary("&&", left!, right!) { Offset = offset }; + } + + return left; + } + + /// + /// Non-associative: a < b < c is a mistake in every language that quietly allows + /// it, so it is refused here rather than silently comparing a boolean to a number. + /// + private AbExNode? ParseComparison() + { + AbExNode? left = ParseAdditive(); + if (_error is not null || !IsOperator("==", "!=", "<", "<=", ">", ">=")) + { + return left; + } + + string op = _current.Text; + int offset = _current.Offset; + Advance(); + + AbExNode? right = ParseAdditive(); + if (_error is not null) return null; + + if (IsOperator("==", "!=", "<", "<=", ">", ">=")) + { + Fail($"Chained comparison '{_current.Text}'. Combine two comparisons with '&&' instead.", + _current.Offset); + return null; + } + + return new AbExBinary(op, left!, right!) { Offset = offset }; + } + + private AbExNode? ParseAdditive() + { + AbExNode? left = ParseMultiplicative(); + while (_error is null && IsOperator("+", "-")) + { + string op = _current.Text; + int offset = _current.Offset; + Advance(); + AbExNode? right = ParseMultiplicative(); + if (_error is not null) return null; + left = new AbExBinary(op, left!, right!) { Offset = offset }; + } + + return left; + } + + private AbExNode? ParseMultiplicative() + { + AbExNode? left = ParseUnary(); + while (_error is null && IsOperator("*", "/", "%")) + { + string op = _current.Text; + int offset = _current.Offset; + Advance(); + AbExNode? right = ParseUnary(); + if (_error is not null) return null; + left = new AbExBinary(op, left!, right!) { Offset = offset }; + } + + return left; + } + + private AbExNode? ParseUnary() + { + if (IsOperator("!", "-")) + { + string op = _current.Text; + int offset = _current.Offset; + Advance(); + AbExNode? operand = ParseUnary(); + if (_error is not null) return null; + return new AbExUnary(op, operand!) { Offset = offset }; + } + + return ParsePrimary(); + } + + private AbExNode? ParsePrimary() + { + int offset = _current.Offset; + + switch (_current.Kind) + { + case AbExTokenKind.Number: + { + decimal value = decimal.Parse(_current.Text, NumberStyles.Number, CultureInfo.InvariantCulture); + Advance(); + return new AbExLiteral(AbExValue.Number(value)) { Offset = offset }; + } + + case AbExTokenKind.String: + { + string text = _current.Text; + Advance(); + return new AbExLiteral(AbExValue.String(text)) { Offset = offset }; + } + + case AbExTokenKind.True: + Advance(); + return new AbExLiteral(AbExValue.True) { Offset = offset }; + + case AbExTokenKind.False: + Advance(); + return new AbExLiteral(AbExValue.False) { Offset = offset }; + + case AbExTokenKind.NullLiteral: + Advance(); + return new AbExLiteral(AbExValue.Null) { Offset = offset }; + + case AbExTokenKind.LParen: + { + Advance(); + AbExNode? inner = ParseOr(); + if (_error is not null) return null; + + if (_current.Kind != AbExTokenKind.RParen) + { + Fail($"Expected ')' but found {_current}.", _current.Offset); + return null; + } + + Advance(); + return inner; + } + + case AbExTokenKind.Identifier: + return ParseCall(); + + case AbExTokenKind.Root: + return ParsePath(); + + case AbExTokenKind.End: + Fail("Expression ends where a value was expected.", offset); + return null; + + default: + Fail($"Expected a value but found {_current}.", offset); + return null; + } + } + + private AbExNode? ParseCall() + { + string name = _current.Text; + int offset = _current.Offset; + Advance(); + + if (_current.Kind != AbExTokenKind.LParen) + { + // The only bare identifiers AbEx has are function names. A path must start with a + // root, and saying so is far more useful than "unexpected token". + Fail($"'{name}' is not a value. Paths start with '$', '$ctx' or '$run'; " + + $"functions are called as {name}(...).", offset); + return null; + } + + Advance(); + var arguments = new List(); + + if (_current.Kind != AbExTokenKind.RParen) + { + while (true) + { + AbExNode? argument = ParseOr(); + if (_error is not null) return null; + arguments.Add(argument!); + + if (_current.Kind == AbExTokenKind.Comma) + { + Advance(); + continue; + } + + break; + } + } + + if (_current.Kind != AbExTokenKind.RParen) + { + Fail($"Expected ')' to close {name}(...) but found {_current}.", _current.Offset); + return null; + } + + Advance(); + return new AbExCall(name, arguments) { Offset = offset }; + } + + private AbExNode? ParsePath() + { + int offset = _current.Offset; + string rootText = _current.Text; + + AbExRoot root; + switch (rootText) + { + case "$": root = AbExRoot.Data; break; + case "$ctx": root = AbExRoot.Context; break; + case "$run": root = AbExRoot.Run; break; + default: + Fail($"Unknown root '{rootText}'. Use '$', '$ctx' or '$run'.", offset); + return null; + } + + Advance(); + var segments = new List(); + + while (true) + { + if (_current.Kind == AbExTokenKind.Dot) + { + Advance(); + if (_current.Kind is not (AbExTokenKind.Identifier or AbExTokenKind.True + or AbExTokenKind.False or AbExTokenKind.NullLiteral)) + { + Fail($"Expected a property name after '.' but found {_current}.", _current.Offset); + return null; + } + + segments.Add(AbExSegment.Property(_current.Text)); + Advance(); + continue; + } + + if (_current.Kind == AbExTokenKind.LBracket) + { + Advance(); + if (_current.Kind != AbExTokenKind.Number) + { + Fail($"Array index must be a literal integer but found {_current}.", _current.Offset); + return null; + } + + if (!int.TryParse(_current.Text, NumberStyles.None, CultureInfo.InvariantCulture, out int index)) + { + Fail($"'{_current.Text}' is not a valid array index.", _current.Offset); + return null; + } + + segments.Add(AbExSegment.At(index)); + Advance(); + + if (_current.Kind != AbExTokenKind.RBracket) + { + Fail($"Expected ']' but found {_current}.", _current.Offset); + return null; + } + + Advance(); + continue; + } + + break; + } + + return new AbExPath(root, segments) { Offset = offset }; + } + } +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs b/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs new file mode 100644 index 0000000..4430182 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExValidator.cs @@ -0,0 +1,126 @@ +namespace Abacus.Run.Dsl.Expressions; + +/// One problem found by static analysis of a parsed expression. +public sealed record AbExIssue(string Message, int Offset, string? Suggestion = null); + +/// What static analysis concluded about an expression. +public sealed record ExpressionFacts( + int Depth, + bool IsDeterministic, + IReadOnlyList Issues) +{ + public bool IsValid => Issues.Count == 0; +} + +/// +/// Static analysis over a parsed tree: everything decidable without a document to evaluate against. +/// +/// +/// Separate from the parser because these are different failures with different audiences. A parse +/// error means the text is not an expression; an issue here means it is a well-formed expression +/// that will never do what it says. +/// +public static class AbExValidator +{ + public static ExpressionFacts Analyse(AbExNode node, int maxDepth = AbExParser.MaxDepth) + { + ArgumentNullException.ThrowIfNull(node); + + var issues = new List(); + bool deterministic = true; + + Walk(node, issues, ref deterministic); + + if (node.Depth > maxDepth) + { + issues.Add(new AbExIssue( + $"Expression nests {node.Depth} deep; the limit is {maxDepth}.", node.Offset)); + } + + return new ExpressionFacts(node.Depth, deterministic, issues); + } + + /// Parses and analyses in one step, folding a parse error into the issue list. + public static ExpressionFacts Check(string expression, int maxDepth = AbExParser.MaxDepth) + { + AbExResult parsed = AbExParser.Parse(expression); + + return parsed.IsSuccess + ? Analyse(parsed.Node!, maxDepth) + : new ExpressionFacts(0, true, [new AbExIssue(parsed.Error!.Message, parsed.Error.Offset)]); + } + + private static void Walk(AbExNode node, List issues, ref bool deterministic) + { + switch (node) + { + case AbExPath path: + if (path.IsNonDeterministic) + { + deterministic = false; + } + + break; + + case AbExUnary unary: + Walk(unary.Operand, issues, ref deterministic); + break; + + case AbExBinary binary: + Walk(binary.Left, issues, ref deterministic); + Walk(binary.Right, issues, ref deterministic); + break; + + case AbExCall call: + CheckCall(call, issues); + foreach (AbExNode argument in call.Arguments) + { + Walk(argument, issues, ref deterministic); + } + + break; + } + } + + private static void CheckCall(AbExCall call, List issues) + { + if (!AbExFunctions.TryGet(call.Name, out AbExFunction function)) + { + string? suggestion = AbExFunctions.Suggest(call.Name); + issues.Add(new AbExIssue( + $"Unknown function '{call.Name}'.", call.Offset, + suggestion is null ? null : $"Did you mean '{suggestion}'?")); + return; + } + + if (!function.AcceptsArity(call.Arguments.Count)) + { + issues.Add(new AbExIssue( + $"'{call.Name}' takes {function.DescribeArity()} argument(s) but was given {call.Arguments.Count}.", + call.Offset)); + } + + // A pattern assembled at run time cannot be checked at authoring time, and an unbounded + // pattern is the one genuinely dangerous thing in the grammar. Requiring a literal keeps + // every regex in a document reviewable by reading the document. + if (call.Name == "matches" && call.Arguments.Count == 2) + { + if (call.Arguments[1] is not AbExLiteral { Value.Kind: AbExValueKind.String } literal) + { + issues.Add(new AbExIssue( + "The pattern argument to 'matches' must be a string literal.", call.Arguments[1].Offset)); + return; + } + + try + { + _ = new System.Text.RegularExpressions.Regex(literal.Value.AsString); + } + catch (ArgumentException ex) + { + issues.Add(new AbExIssue( + $"Invalid regular expression: {ex.Message}", call.Arguments[1].Offset)); + } + } + } +} diff --git a/src/Abacus.Run.Dsl/Expressions/AbExValue.cs b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs new file mode 100644 index 0000000..3370757 --- /dev/null +++ b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace Abacus.Run.Dsl.Expressions; + +public enum AbExValueKind +{ + /// + /// The path resolved to nothing. Distinct from , which is a JSON value the + /// document actually contains. + /// + Absent, + Null, + Boolean, + Number, + String, + Array, + Object +} + +/// +/// One value in an AbEx evaluation. Absence is a value rather than an exception, which is what makes +/// the evaluator total: no expression over any document can throw. +/// +public readonly struct AbExValue : IEquatable +{ + private readonly decimal _number; + private readonly bool _boolean; + private readonly string? _string; + private readonly JsonNode? _node; + + private AbExValue(AbExValueKind kind, decimal number = 0, bool boolean = false, + string? text = null, JsonNode? node = null) + { + Kind = kind; + _number = number; + _boolean = boolean; + _string = text; + _node = node; + } + + public AbExValueKind Kind { get; } + + public static AbExValue Absent { get; } = new(AbExValueKind.Absent); + public static AbExValue Null { get; } = new(AbExValueKind.Null); + public static AbExValue True { get; } = new(AbExValueKind.Boolean, boolean: true); + public static AbExValue False { get; } = new(AbExValueKind.Boolean, boolean: false); + + public static AbExValue Bool(bool value) => value ? True : False; + public static AbExValue Number(decimal value) => new(AbExValueKind.Number, number: value); + public static AbExValue String(string value) => new(AbExValueKind.String, text: value); + + public bool IsAbsent => Kind == AbExValueKind.Absent; + public bool IsTruthy => Kind == AbExValueKind.Boolean && _boolean; + + public bool AsBoolean => _boolean; + public decimal AsNumber => _number; + public string AsString => _string ?? string.Empty; + public JsonNode? AsNode => _node; + + /// + /// Classifies a into a value. A null reference is + /// only when the caller means "not there"; a JSON null arrives as and + /// maps to . + /// + public static AbExValue FromNode(JsonNode? node) + { + switch (node) + { + case null: + return Null; + + case JsonArray array: + return new AbExValue(AbExValueKind.Array, node: array); + + case JsonObject obj: + return new AbExValue(AbExValueKind.Object, node: obj); + + case JsonValue value: + if (value.TryGetValue(out bool b)) return Bool(b); + if (value.TryGetValue(out decimal d)) return Number(d); + if (value.TryGetValue(out string? s) && s is not null) return String(s); + + // A JsonValue wrapping something exotic still has a JSON text form; fall back to it + // rather than reporting absence for a value that demonstrably exists. + return String(value.ToJsonString().Trim('"')); + + default: + return String(node.ToJsonString()); + } + } + + /// Length as len() defines it: characters, elements, or properties. + public int Length => Kind switch + { + AbExValueKind.String => AsString.Length, + AbExValueKind.Array => ((JsonArray)_node!).Count, + AbExValueKind.Object => ((JsonObject)_node!).Count, + _ => 0 + }; + + /// Renders for template interpolation. Absent and null both render as empty. + public string ToText() => Kind switch + { + AbExValueKind.Absent or AbExValueKind.Null => string.Empty, + AbExValueKind.Boolean => _boolean ? "true" : "false", + AbExValueKind.Number => FormatNumber(_number), + AbExValueKind.String => AsString, + _ => _node?.ToJsonString() ?? string.Empty + }; + + /// Converts back to a JSON node for writing into an envelope. + public JsonNode? ToNode() => Kind switch + { + AbExValueKind.Absent or AbExValueKind.Null => null, + AbExValueKind.Boolean => JsonValue.Create(_boolean), + AbExValueKind.Number => JsonValue.Create(_number), + AbExValueKind.String => JsonValue.Create(AsString), + _ => _node?.DeepClone() + }; + + /// + /// Trailing zeros are dropped so 1.50 and 1.5 render alike — decimal preserves + /// scale, and a template that produced "1.50" where the author wrote arithmetic would look wrong. + /// + internal static string FormatNumber(decimal value) + { + decimal normalized = value == decimal.Truncate(value) && Math.Abs(value) < 1e15m + ? decimal.Truncate(value) + : value / 1.000000000000000000000000000000000m; + + return normalized.ToString(CultureInfo.InvariantCulture); + } + + public bool Equals(AbExValue other) + { + if (Kind != other.Kind) + { + return false; + } + + return Kind switch + { + AbExValueKind.Absent or AbExValueKind.Null => true, + AbExValueKind.Boolean => _boolean == other._boolean, + AbExValueKind.Number => _number == other._number, + AbExValueKind.String => string.Equals(_string, other._string, StringComparison.Ordinal), + _ => string.Equals(_node?.ToJsonString(), other._node?.ToJsonString(), StringComparison.Ordinal) + }; + } + + public override bool Equals(object? obj) => obj is AbExValue other && Equals(other); + + public override int GetHashCode() => Kind switch + { + AbExValueKind.Boolean => _boolean.GetHashCode(), + AbExValueKind.Number => _number.GetHashCode(), + AbExValueKind.String => StringComparer.Ordinal.GetHashCode(_string ?? string.Empty), + _ => (int)Kind + }; + + public override string ToString() => Kind == AbExValueKind.Absent ? "" : ToText(); +} diff --git a/src/Abacus.Run.Dsl/Interpretation/DslMessage.cs b/src/Abacus.Run.Dsl/Interpretation/DslMessage.cs new file mode 100644 index 0000000..296a14d --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/DslMessage.cs @@ -0,0 +1,120 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Executors; + +namespace Abacus.Run.Dsl.Interpretation; + +/// Provenance the interpreter maintains. Read-only to expressions. +public sealed record DslMeta(string? Node = null, int Superstep = 0, int Attempt = 1); + +/// +/// The single message type every DSL node sends and receives. +/// +/// +/// +/// One envelope type is what lets a JSON document describe a graph whose C# API is generic: every +/// node is HostExecutor<DslMessage, DslMessage>, so every edge type-checks by +/// construction and there is no type-flow analysis to write. +/// +/// +/// is the reason an expression eleven nodes deep can still read the start context. +/// A compiled node closes over whatever C# scope it likes; a document has no scope, so the envelope +/// carries one. It is a reference copy — cloned once at start and never again — so a large context +/// is not duplicated per node. +/// +/// +/// Immutable, so a message a checkpoint captured cannot be mutated by a node that runs later. +/// +/// +public sealed class DslMessage : ITemplateBindingSource +{ + private static readonly ConcurrentDictionary TemplateCache = + new(StringComparer.Ordinal); + + public DslMessage(JsonNode? ctx, JsonNode? data, DslMeta? meta = null, JsonObject? run = null) + { + Ctx = ctx; + Data = data; + Meta = meta ?? new DslMeta(); + Run = run ?? []; + } + + /// The start context, frozen. Copied through every node unchanged. + public JsonNode? Ctx { get; } + + /// The current value. This is what a node reads and what it replaces. + public JsonNode? Data { get; } + + public DslMeta Meta { get; } + + /// + /// Backs the $run expression root. Ambient runtime identity rather than payload, so it is + /// not part of the envelope a node reads or writes. + /// + public JsonObject Run { get; } + + /// Replaces , carrying through untouched. + public DslMessage WithData(JsonNode? data) => new(Ctx, data, Meta, Run); + + public DslMessage WithMeta(DslMeta meta) => new(Ctx, Data, meta, Run); + + public DslMessage WithRun(JsonObject run) => new(Ctx, Data, Meta, run); + + /// + /// Resolves a {{ ... }} placeholder as a full AbEx expression, so a template reaches the + /// same three roots a condition does. Parses are cached: the same handful of templates are + /// rendered once per invocation for the life of the host. + /// + string? ITemplateBindingSource.Resolve(string expression) + { + AbExResult parsed = TemplateCache.GetOrAdd(expression, AbExParser.Parse); + + // An unparseable placeholder renders as empty rather than throwing. The semantic validator + // has already rejected the document if it got the chance; at run time a broken template must + // not take down a run that is otherwise fine. + if (!parsed.IsSuccess) + { + return null; + } + + AbExValue value = AbExEvaluator.Evaluate(parsed.Node!, ToExpressionContext(Run)); + return value.IsAbsent ? null : value.ToText(); + } + + /// + /// Opens an envelope from a start context. data begins as a copy of the context, so the + /// first node reads the payload through $ as well as $ctx — a workflow whose first + /// node needs nothing else should not have to say $ctx to reach it. + /// + public static DslMessage Start(JsonElement context) + { + JsonNode? ctx = JsonSerializer.Deserialize(context); + return new DslMessage(ctx, ctx?.DeepClone()); + } + + public static DslMessage Start(JsonNode? context) + => new(context, context?.DeepClone()); + + /// Binds this envelope to the three expression roots. + public AbExContext ToExpressionContext(JsonObject run) => new(Data, Ctx, run); + + /// Builds the $run object from the values the runtime knows. + public static JsonObject RunMetadata( + string instanceId, string? tenantId, string workflow, string version, + int attempt, int superstep, DateTimeOffset now) => + new() + { + ["instanceId"] = instanceId, + ["tenantId"] = tenantId, + ["workflow"] = workflow, + ["version"] = version, + ["attempt"] = attempt, + ["superstep"] = superstep, + ["now"] = now.ToString("O") + }; + + public override string ToString() + => $"DslMessage(node={Meta.Node ?? "-"}, data={Data?.ToJsonString() ?? "null"})"; +} diff --git a/src/Abacus.Run/Executors/TemplateEngine.cs b/src/Abacus.Run/Executors/TemplateEngine.cs index aff59db..f5b9d6e 100644 --- a/src/Abacus.Run/Executors/TemplateEngine.cs +++ b/src/Abacus.Run/Executors/TemplateEngine.cs @@ -3,6 +3,25 @@ namespace Abacus.Run.Executors; +/// +/// Implemented by a message type that resolves its own template placeholders. +/// +/// +/// The default resolution walks dotted paths by reflection, which suits a POCO context and nothing +/// else. A message carrying more than one addressable object — the DSL envelope carries the start +/// context alongside the current value — cannot be expressed as one dotted path over one root, so it +/// takes the placeholder text and answers for itself. Opt-in: a type that does not implement this is +/// resolved exactly as before. +/// +public interface ITemplateBindingSource +{ + /// + /// Resolves the text between {{ and }}. Returns null when it resolves to nothing, + /// which renders as empty — a template must not fail a run over an absent field. + /// + string? Resolve(string expression); +} + /// Named values available to a template, resolved by dotted path. public sealed class TemplateBindings { @@ -22,6 +41,13 @@ public sealed class TemplateBindings { ArgumentException.ThrowIfNullOrEmpty(path); + // Checked before the dotted-path walk so a self-resolving message keeps full control of its + // own placeholder syntax rather than having it parsed on its behalf first. + if (_root is ITemplateBindingSource source) + { + return source.Resolve(path); + } + string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); object? current = _root; diff --git a/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs b/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs new file mode 100644 index 0000000..bab349c --- /dev/null +++ b/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs @@ -0,0 +1,346 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Expressions; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class AbExEvaluatorTests +{ + private static readonly AbExContext Sample = new( + Data: JsonNode.Parse(""" + { + "total": 429.5, + "count": 3, + "status": "settled", + "flag": true, + "nothing": null, + "lines": [ { "sku": "A-1", "qty": 2 }, { "sku": "B-2", "qty": 5 } ], + "nested": { "deep": { "value": 7 } } + } + """), + Context: JsonNode.Parse("""{ "orderId": "ORD-1", "tier": "gold" }"""), + Run: new JsonObject + { + ["instanceId"] = "inst-1", + ["attempt"] = 2, + ["now"] = "2026-08-17T00:00:00.0000000+00:00" + }); + + private static AbExValue Eval(string expression, AbExContext? context = null) + => AbExEvaluator.Evaluate(AbExParser.ParseOrThrow(expression), context ?? Sample); + + private static bool Cond(string expression, AbExContext? context = null) + => AbExEvaluator.EvaluateCondition(AbExParser.ParseOrThrow(expression), context ?? Sample); + + // ---- path resolution ------------------------------------------------------------------ + + [Fact] + public void Resolves_data_root() => Eval("$.total").AsNumber.Should().Be(429.5m); + + [Fact] + public void Resolves_context_root() => Eval("$ctx.orderId").AsString.Should().Be("ORD-1"); + + [Fact] + public void Resolves_run_root() => Eval("$run.instanceId").AsString.Should().Be("inst-1"); + + [Fact] + public void Resolves_array_index() => Eval("$.lines[1].sku").AsString.Should().Be("B-2"); + + [Fact] + public void Resolves_nested_object() => Eval("$.nested.deep.value").AsNumber.Should().Be(7); + + [Fact] + public void Bare_root_returns_the_whole_object() + => Eval("$").Kind.Should().Be(AbExValueKind.Object); + + [Theory] + [InlineData("$.missing")] + [InlineData("$.nested.missing")] + [InlineData("$.nested.deep.missing")] + [InlineData("$.lines[9]")] + [InlineData("$.total.deeper")] + [InlineData("$ctx.missing")] + public void Missing_paths_are_absent(string expression) + => Eval(expression).IsAbsent.Should().BeTrue(); + + [Fact] + public void Json_null_is_null_not_absent() + { + Eval("$.nothing").Kind.Should().Be(AbExValueKind.Null); + Eval("$.nothing").IsAbsent.Should().BeFalse(); + } + + [Fact] + public void Path_into_a_null_root_is_absent() + => Eval("$.a.b", new AbExContext(null, null, [])).IsAbsent.Should().BeTrue(); + + // ---- strict boolean conditions -------------------------------------------------------- + + [Fact] + public void Only_true_is_true() => Cond("$.flag").Should().BeTrue(); + + [Theory] + [InlineData("$.total")] // a number + [InlineData("$.status")] // a non-empty string + [InlineData("$.count")] + [InlineData("$.nothing")] // json null + [InlineData("$.missing")] // absent + [InlineData("$")] // an object + [InlineData("$.lines")] // an array + public void Non_booleans_are_never_true(string expression) + => Cond(expression).Should().BeFalse(); + + [Fact] + public void Zero_and_empty_string_are_false() + { + Cond("0").Should().BeFalse(); + Cond("''").Should().BeFalse(); + } + + // ---- comparison ----------------------------------------------------------------------- + + [Theory] + [InlineData("$.total > 400", true)] + [InlineData("$.total > 500", false)] + [InlineData("$.total >= 429.5", true)] + [InlineData("$.total < 429.5", false)] + [InlineData("$.total <= 429.5", true)] + [InlineData("$.count == 3", true)] + [InlineData("$.count != 3", false)] + [InlineData("$.count != 4", true)] + public void Numeric_comparison(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Theory] + [InlineData("$.status == 'settled'", true)] + [InlineData("$.status == 'SETTLED'", false)] + [InlineData("'a' < 'b'", true)] + [InlineData("'b' < 'a'", false)] + public void Ordinal_string_comparison(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Fact] + public void Null_compares_equal_to_null() => Cond("$.nothing == null").Should().BeTrue(); + + [Theory] + [InlineData("$.status == 3")] + [InlineData("$.count == 'three'")] + [InlineData("$.count > 'a'")] + [InlineData("$.flag == 1")] + public void Cross_type_comparison_is_false(string expression) + => Cond(expression).Should().BeFalse(); + + /// + /// Absence makes both '==' and '!=' false. A document asking whether a field it never set + /// differs from a value must not be told "yes"; has() is how presence is asked about. + /// + [Theory] + [InlineData("$.missing == 1")] + [InlineData("$.missing != 1")] + [InlineData("$.missing == null")] + [InlineData("$.missing > 0")] + [InlineData("$.missing < 0")] + [InlineData("$.missing == $.alsoMissing")] + public void Absence_makes_every_comparison_false(string expression) + => Cond(expression).Should().BeFalse(); + + // ---- boolean operators ---------------------------------------------------------------- + + [Theory] + [InlineData("true && true", true)] + [InlineData("true && false", false)] + [InlineData("false && true", false)] + [InlineData("true || false", true)] + [InlineData("false || false", false)] + [InlineData("!true", false)] + [InlineData("!false", true)] + public void Boolean_algebra(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Fact] + public void And_short_circuits_past_an_absent_right_operand() + => Cond("false && $.missing.deep").Should().BeFalse(); + + [Fact] + public void Or_short_circuits_past_an_absent_right_operand() + => Cond("true || $.missing.deep").Should().BeTrue(); + + [Fact] + public void Non_boolean_operand_makes_the_result_absent() + { + Eval("$.total && true").IsAbsent.Should().BeTrue(); + Eval("true && $.total").IsAbsent.Should().BeTrue(); + Eval("!$.total").IsAbsent.Should().BeTrue(); + } + + [Fact] + public void Guard_pattern_works() + { + Cond("has($.nested) && $.nested.deep.value > 5").Should().BeTrue(); + Cond("has($.absent) && $.absent.deep.value > 5").Should().BeFalse(); + } + + // ---- arithmetic ----------------------------------------------------------------------- + + [Theory] + [InlineData("1 + 2", 3)] + [InlineData("5 - 3", 2)] + [InlineData("4 * 3", 12)] + [InlineData("10 / 4", 2.5)] + [InlineData("10 % 3", 1)] + [InlineData("-5", -5)] + [InlineData("$.lines[0].qty * 10", 20)] + public void Arithmetic(string expression, decimal expected) + => Eval(expression).AsNumber.Should().Be(expected); + + /// These documents price orders; binary floating point is the wrong default. + [Fact] + public void Arithmetic_is_decimal_not_binary_float() + => Eval("0.1 + 0.2").AsNumber.Should().Be(0.3m); + + [Theory] + [InlineData("1 / 0")] + [InlineData("1 % 0")] + public void Division_by_zero_is_absent(string expression) + => Eval(expression).IsAbsent.Should().BeTrue(); + + [Theory] + [InlineData("$.status + 1")] + [InlineData("'a' + 'b'")] // no string concatenation: that is what templates are for + [InlineData("$.missing + 1")] + [InlineData("$.flag * 2")] + public void Non_numeric_arithmetic_is_absent(string expression) + => Eval(expression).IsAbsent.Should().BeTrue(); + + // ---- functions ------------------------------------------------------------------------ + + [Theory] + [InlineData("len($.status)", 7)] + [InlineData("len($.lines)", 2)] + [InlineData("len($)", 7)] + [InlineData("len($.missing)", 0)] + [InlineData("len($.total)", 0)] + public void Len(string expression, int expected) + => Eval(expression).AsNumber.Should().Be(expected); + + [Theory] + [InlineData("has($.total)", true)] + [InlineData("has($.missing)", false)] + [InlineData("has($.nothing)", true)] // a json null is present + [InlineData("has($.lines[1])", true)] + [InlineData("has($.lines[9])", false)] + public void Has(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Fact] + public void Case_folding() + { + Eval("lower($.status)").AsString.Should().Be("settled"); + Eval("upper($.status)").AsString.Should().Be("SETTLED"); + Eval("lower($.total)").IsAbsent.Should().BeTrue(); + } + + [Theory] + [InlineData("contains($.status, 'ettl')", true)] + [InlineData("contains($.status, 'xyz')", false)] + [InlineData("startsWith($.status, 'set')", true)] + [InlineData("startsWith($.status, 'Set')", false)] + [InlineData("endsWith($.status, 'led')", true)] + public void String_tests(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Theory] + [InlineData("matches($.lines[0].sku, '^[A-Z]-\\\\d+$')", true)] + [InlineData("matches($.status, '^\\\\d+$')", false)] + public void Matches(string expression, bool expected) + => Cond(expression).Should().Be(expected); + + [Fact] + public void Matches_with_an_invalid_pattern_is_absent() + => Eval("matches($.status, '[')").IsAbsent.Should().BeTrue(); + + [Fact] + public void Coalesce_takes_the_first_present_non_null() + { + Eval("coalesce($.missing, $.nothing, $.status)").AsString.Should().Be("settled"); + Eval("coalesce($.total, $.status)").AsNumber.Should().Be(429.5m); + Eval("coalesce($.missing, $.alsoMissing)").IsAbsent.Should().BeTrue(); + } + + [Fact] + public void Coercion() + { + Eval("number('42.5')").AsNumber.Should().Be(42.5m); + Eval("number($.total)").AsNumber.Should().Be(429.5m); + Eval("number('abc')").IsAbsent.Should().BeTrue(); + Eval("number($.flag)").IsAbsent.Should().BeTrue(); + + Eval("string($.total)").AsString.Should().Be("429.5"); + Eval("string($.count)").AsString.Should().Be("3"); + Eval("string($.flag)").AsString.Should().Be("true"); + Eval("string($.missing)").IsAbsent.Should().BeTrue(); + + Eval("bool('true')").AsBoolean.Should().BeTrue(); + Eval("bool($.flag)").AsBoolean.Should().BeTrue(); + Eval("bool('yes')").IsAbsent.Should().BeTrue(); + } + + [Fact] + public void Unknown_function_evaluates_to_absent() + { + // Unreachable through a validated document; absent keeps the evaluator total if it happens. + var call = new AbExCall("nope", [new AbExLiteral(AbExValue.Number(1))]); + AbExEvaluator.Evaluate(call, Sample).IsAbsent.Should().BeTrue(); + } + + [Fact] + public void Wrong_arity_evaluates_to_absent() + { + var call = new AbExCall("len", []); + AbExEvaluator.Evaluate(call, Sample).IsAbsent.Should().BeTrue(); + } + + // ---- totality ------------------------------------------------------------------------- + + [Theory] + [InlineData("$.a.b.c.d.e")] + [InlineData("$.lines[99].sku.deeper")] + [InlineData("len($.missing) / 0")] + [InlineData("upper($.lines) + $.nothing")] + [InlineData("!$.missing && $.missing > $.missing")] + public void Evaluation_never_throws(string expression) + { + Action act = () => Eval(expression); + act.Should().NotThrow(); + } + + [Fact] + public void Evaluation_over_an_empty_context_never_throws() + { + foreach (string expression in new[] + { "$.a", "$ctx.a", "$run.a", "len($)", "$ + 1", "has($.x)", "$.a == $.b" }) + { + Action act = () => Eval(expression, AbExContext.Empty); + act.Should().NotThrow($"'{expression}' must be total"); + } + } + + // ---- rendering ------------------------------------------------------------------------ + + [Theory] + [InlineData("$.total", "429.5")] + [InlineData("$.count", "3")] + [InlineData("$.status", "settled")] + [InlineData("$.flag", "true")] + [InlineData("$.nothing", "")] + [InlineData("$.missing", "")] + [InlineData("1 + 2", "3")] + [InlineData("10 / 4", "2.5")] + public void ToText_renders_for_templates(string expression, string expected) + => Eval(expression).ToText().Should().Be(expected); + + [Fact] + public void Trailing_zeros_are_trimmed() + => AbExValue.Number(1.50m).ToText().Should().Be("1.5"); +} diff --git a/tests/Abacus.Run.DslTests/AbExParserTests.cs b/tests/Abacus.Run.DslTests/AbExParserTests.cs new file mode 100644 index 0000000..c71ef35 --- /dev/null +++ b/tests/Abacus.Run.DslTests/AbExParserTests.cs @@ -0,0 +1,273 @@ +using Abacus.Run.Dsl.Expressions; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class AbExParserTests +{ + private static AbExNode Parse(string expression) + { + AbExResult result = AbExParser.Parse(expression); + result.IsSuccess.Should().BeTrue( + $"'{expression}' should parse but failed: {result.Error?.Message}"); + return result.Node!; + } + + private static AbExError ParseError(string expression) + { + AbExResult result = AbExParser.Parse(expression); + result.IsSuccess.Should().BeFalse($"'{expression}' should not parse"); + return result.Error!; + } + + [Theory] + [InlineData("$")] + [InlineData("$.total")] + [InlineData("$ctx.orderId")] + [InlineData("$run.attempt")] + [InlineData("$.lines[0].sku")] + [InlineData("$.a.b.c[3][4].d")] + public void Paths_parse(string expression) => Parse(expression).Should().BeOfType(); + + [Fact] + public void Path_records_root_and_segments() + { + var path = (AbExPath)Parse("$ctx.lines[2].sku"); + + path.Root.Should().Be(AbExRoot.Context); + path.Segments.Should().HaveCount(3); + path.Segments[0].Name.Should().Be("lines"); + path.Segments[1].IsIndex.Should().BeTrue(); + path.Segments[1].Index.Should().Be(2); + path.Segments[2].Name.Should().Be("sku"); + } + + [Fact] + public void Bare_root_has_no_segments() + { + var path = (AbExPath)Parse("$"); + path.Root.Should().Be(AbExRoot.Data); + path.Segments.Should().BeEmpty(); + } + + [Theory] + [InlineData("1", 1)] + [InlineData("42", 42)] + [InlineData("3.5", 3.5)] + [InlineData("0.1", 0.1)] + public void Number_literals_parse(string expression, decimal expected) + => ((AbExLiteral)Parse(expression)).Value.AsNumber.Should().Be(expected); + + [Theory] + [InlineData("'settled'", "settled")] + [InlineData("\"settled\"", "settled")] + [InlineData("'it\\'s'", "it's")] + [InlineData("'a\\nb'", "a\nb")] + [InlineData("''", "")] + public void String_literals_parse(string expression, string expected) + => ((AbExLiteral)Parse(expression)).Value.AsString.Should().Be(expected); + + [Theory] + [InlineData("true")] + [InlineData("false")] + [InlineData("null")] + public void Keyword_literals_parse(string expression) + => Parse(expression).Should().BeOfType(); + + [Fact] + public void Or_binds_looser_than_and() + { + var root = (AbExBinary)Parse("$.a || $.b && $.c"); + + root.Operator.Should().Be("||"); + ((AbExBinary)root.Right).Operator.Should().Be("&&"); + } + + [Fact] + public void And_binds_looser_than_comparison() + { + var root = (AbExBinary)Parse("$.a > 1 && $.b < 2"); + + root.Operator.Should().Be("&&"); + ((AbExBinary)root.Left).Operator.Should().Be(">"); + ((AbExBinary)root.Right).Operator.Should().Be("<"); + } + + [Fact] + public void Comparison_binds_looser_than_arithmetic() + { + var root = (AbExBinary)Parse("$.a + 1 > $.b * 2"); + + root.Operator.Should().Be(">"); + ((AbExBinary)root.Left).Operator.Should().Be("+"); + ((AbExBinary)root.Right).Operator.Should().Be("*"); + } + + [Fact] + public void Multiplication_binds_tighter_than_addition() + { + var root = (AbExBinary)Parse("1 + 2 * 3"); + + root.Operator.Should().Be("+"); + ((AbExBinary)root.Right).Operator.Should().Be("*"); + } + + [Fact] + public void Addition_is_left_associative() + { + var root = (AbExBinary)Parse("1 - 2 - 3"); + + root.Operator.Should().Be("-"); + ((AbExBinary)root.Left).Operator.Should().Be("-"); + ((AbExLiteral)root.Right).Value.AsNumber.Should().Be(3); + } + + [Fact] + public void Parentheses_override_precedence() + { + var root = (AbExBinary)Parse("(1 + 2) * 3"); + root.Operator.Should().Be("*"); + } + + /// + /// A deviation from the grammar as first written, where '!' sat between '&&' and + /// comparison. Standard precedence is what an author expects, and '!has($.x) && ...' is + /// the common shape. + /// + [Fact] + public void Not_binds_tighter_than_comparison() + { + var root = (AbExBinary)Parse("!$.a == $.b"); + + root.Operator.Should().Be("=="); + root.Left.Should().BeOfType(); + } + + [Fact] + public void Unary_minus_parses() + { + var root = (AbExUnary)Parse("-$.total"); + root.Operator.Should().Be("-"); + } + + [Fact] + public void Calls_parse_with_arguments() + { + var call = (AbExCall)Parse("contains($.sku, 'ABC')"); + + call.Name.Should().Be("contains"); + call.Arguments.Should().HaveCount(2); + } + + [Fact] + public void Calls_parse_with_no_arguments() + => ((AbExCall)Parse("len()")).Arguments.Should().BeEmpty(); + + [Fact] + public void Calls_nest() + { + var call = (AbExCall)Parse("lower(coalesce($.a, $.b, 'x'))"); + + call.Name.Should().Be("lower"); + ((AbExCall)call.Arguments[0]).Arguments.Should().HaveCount(3); + } + + [Fact] + public void Depth_reflects_nesting() + { + Parse("1").Depth.Should().Be(1); + Parse("1 + 2").Depth.Should().Be(2); + Parse("1 + 2 + 3").Depth.Should().Be(3); + } + + [Fact] + public void Non_deterministic_path_is_flagged() + { + ((AbExPath)Parse("$run.now")).IsNonDeterministic.Should().BeTrue(); + ((AbExPath)Parse("$run.attempt")).IsNonDeterministic.Should().BeFalse(); + ((AbExPath)Parse("$.now")).IsNonDeterministic.Should().BeFalse(); + } + + // ---- failures ------------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Empty_expression_fails(string? expression) + => AbExParser.Parse(expression).IsSuccess.Should().BeFalse(); + + [Fact] + public void Single_equals_names_the_mistake() + => ParseError("$.a = 1").Message.Should().Contain("'=='"); + + [Fact] + public void Unterminated_string_fails() + => ParseError("'abc").Message.Should().Contain("Unterminated"); + + [Fact] + public void Unclosed_paren_fails() + => ParseError("(1 + 2").Message.Should().Contain("')'"); + + [Fact] + public void Unclosed_call_fails() + => ParseError("len($.a").Message.Should().Contain("')'"); + + [Fact] + public void Unclosed_bracket_fails() + => ParseError("$.a[0").Message.Should().Contain("']'"); + + [Fact] + public void Trailing_tokens_fail() + => ParseError("1 2").Message.Should().Contain("after a complete expression"); + + [Fact] + public void Chained_comparison_is_refused() + => ParseError("1 < 2 < 3").Message.Should().Contain("Chained comparison"); + + [Fact] + public void Bare_identifier_explains_roots() + => ParseError("total").Message.Should().Contain("$ctx"); + + [Fact] + public void Unknown_root_is_named() + => ParseError("$nope.a").Message.Should().Contain("Unknown root"); + + [Fact] + public void Non_literal_array_index_fails() + => ParseError("$.a[$.i]").Message.Should().Contain("literal integer"); + + [Fact] + public void Property_after_dot_is_required() + => ParseError("$.a.").Message.Should().Contain("property name"); + + [Fact] + public void Unexpected_character_is_reported() + => ParseError("$.a @ 1").Message.Should().Contain("Unexpected character"); + + [Fact] + public void Expression_deeper_than_the_limit_fails() + { + string deep = string.Join(" + ", Enumerable.Range(0, AbExParser.MaxDepth + 5).Select(i => i.ToString())); + ParseError(deep).Message.Should().Contain("the limit is"); + } + + [Theory] + [InlineData("$.a = 1", 4)] + [InlineData("1 2", 2)] + [InlineData("(1 + 2", 6)] + public void Errors_carry_the_offset(string expression, int expectedOffset) + => ParseError(expression).Offset.Should().Be(expectedOffset); + + [Fact] + public void ParseOrThrow_throws_on_bad_input() + { + Action act = () => AbExParser.ParseOrThrow("$.a = 1"); + act.Should().Throw().WithMessage("*=='*"); + } + + [Fact] + public void ParseOrThrow_returns_the_tree_on_good_input() + => AbExParser.ParseOrThrow("$.a").Should().BeOfType(); +} diff --git a/tests/Abacus.Run.DslTests/AbExValidatorTests.cs b/tests/Abacus.Run.DslTests/AbExValidatorTests.cs new file mode 100644 index 0000000..c557f8a --- /dev/null +++ b/tests/Abacus.Run.DslTests/AbExValidatorTests.cs @@ -0,0 +1,128 @@ +using Abacus.Run.Dsl.Expressions; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class AbExValidatorTests +{ + [Theory] + [InlineData("$.total > 0")] + [InlineData("has($.a) && lower($.b) == 'x'")] + [InlineData("coalesce($.a, $.b, $.c, 'fallback')")] + [InlineData("matches($.sku, '^[A-Z]+$')")] + public void Valid_expressions_have_no_issues(string expression) + => AbExValidator.Check(expression).IsValid.Should().BeTrue(); + + [Fact] + public void Unknown_function_is_reported() + { + ExpressionFacts facts = AbExValidator.Check("lookupCustomer($.id)"); + + facts.IsValid.Should().BeFalse(); + facts.Issues.Should().ContainSingle() + .Which.Message.Should().Contain("Unknown function 'lookupCustomer'"); + } + + [Theory] + [InlineData("lenn($.a)", "len")] + [InlineData("upperr($.a)", "upper")] + [InlineData("startswith($.a, 'x')", "startsWith")] + [InlineData("containz($.a, 'x')", "contains")] + public void Near_misses_get_a_suggestion(string expression, string expected) + => AbExValidator.Check(expression).Issues[0].Suggestion.Should().Contain(expected); + + [Fact] + public void A_wildly_wrong_name_gets_no_suggestion() + => AbExValidator.Check("zzzzzzzzzzzz($.a)").Issues[0].Suggestion.Should().BeNull(); + + [Theory] + [InlineData("len()", "1")] + [InlineData("len($.a, $.b)", "1")] + [InlineData("contains($.a)", "2")] + [InlineData("coalesce()", "at least 1")] + public void Wrong_arity_is_reported(string expression, string expectedArity) + { + ExpressionFacts facts = AbExValidator.Check(expression); + + facts.IsValid.Should().BeFalse(); + facts.Issues[0].Message.Should().Contain(expectedArity); + } + + [Fact] + public void Variadic_coalesce_accepts_many_arguments() + => AbExValidator.Check("coalesce($.a, $.b, $.c, $.d, $.e, 'z')").IsValid.Should().BeTrue(); + + /// + /// A pattern assembled at run time cannot be reviewed by reading the document, and an unbounded + /// pattern is the one genuinely dangerous construct in the grammar. + /// + [Fact] + public void Matches_requires_a_literal_pattern() + { + ExpressionFacts facts = AbExValidator.Check("matches($.a, $.pattern)"); + + facts.IsValid.Should().BeFalse(); + facts.Issues[0].Message.Should().Contain("must be a string literal"); + } + + [Fact] + public void Matches_rejects_an_invalid_pattern() + => AbExValidator.Check("matches($.a, '[')").Issues[0].Message + .Should().Contain("Invalid regular expression"); + + [Fact] + public void Determinism_is_reported() + { + AbExValidator.Check("$.total > 0").IsDeterministic.Should().BeTrue(); + AbExValidator.Check("$run.attempt > 1").IsDeterministic.Should().BeTrue(); + AbExValidator.Check("$run.now > '2020'").IsDeterministic.Should().BeFalse(); + AbExValidator.Check("has($.a) && $run.now != null").IsDeterministic.Should().BeFalse(); + } + + [Fact] + public void Depth_is_reported() + => AbExValidator.Check("1 + 2 + 3").Depth.Should().Be(3); + + [Fact] + public void Depth_beyond_a_custom_limit_is_reported() + { + ExpressionFacts facts = AbExValidator.Check("1 + 2 + 3 + 4", maxDepth: 2); + + facts.IsValid.Should().BeFalse(); + facts.Issues[0].Message.Should().Contain("the limit is 2"); + } + + [Fact] + public void A_parse_error_becomes_an_issue() + { + ExpressionFacts facts = AbExValidator.Check("$.a = 1"); + + facts.IsValid.Should().BeFalse(); + facts.Issues[0].Message.Should().Contain("'=='"); + facts.Issues[0].Offset.Should().Be(4); + } + + [Fact] + public void Issues_from_nested_calls_are_collected() + { + ExpressionFacts facts = AbExValidator.Check("lower(nope($.a)) == upper(alsoNope($.b))"); + facts.Issues.Should().HaveCount(2); + } + + [Fact] + public void Edit_distance_is_case_insensitive() + => AbExFunctions.EditDistance("STARTSWITH", "startsWith").Should().Be(0); + + [Fact] + public void Every_registered_function_is_self_consistent() + { + foreach (string name in AbExFunctions.Names) + { + AbExFunctions.TryGet(name, out AbExFunction function).Should().BeTrue(); + function.Name.Should().Be(name); + function.MinArguments.Should().BeGreaterThan(0); + function.MaxArguments.Should().BeGreaterThanOrEqualTo(function.MinArguments); + } + } +} diff --git a/tests/Abacus.Run.DslTests/Abacus.Run.DslTests.csproj b/tests/Abacus.Run.DslTests/Abacus.Run.DslTests.csproj new file mode 100644 index 0000000..6ca1092 --- /dev/null +++ b/tests/Abacus.Run.DslTests/Abacus.Run.DslTests.csproj @@ -0,0 +1,19 @@ + + + false + true + + + + + + + + + + + + + + + diff --git a/tests/Abacus.Run.DslTests/DslMessageTests.cs b/tests/Abacus.Run.DslTests/DslMessageTests.cs new file mode 100644 index 0000000..d7bdd9a --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslMessageTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Executors; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class DslMessageTests +{ + private static DslMessage Sample() + { + JsonNode ctx = JsonNode.Parse("""{ "orderId": "ORD-1", "tier": "gold" }""")!; + return new DslMessage( + ctx, + JsonNode.Parse("""{ "total": 429.5, "status": "settled" }"""), + new DslMeta("price", 2, 1), + DslMessage.RunMetadata("inst-1", "t-1", "order", "1.0.0", 1, 2, + DateTimeOffset.Parse("2026-08-17T00:00:00Z"))); + } + + [Fact] + public void Start_seeds_data_from_the_context() + { + using JsonDocument document = JsonDocument.Parse("""{ "orderId": "ORD-9" }"""); + DslMessage message = DslMessage.Start(document.RootElement); + + message.Ctx!.ToJsonString().Should().Contain("ORD-9"); + message.Data!.ToJsonString().Should().Contain("ORD-9"); + } + + /// + /// A node reading '$' on the first step must not see the context mutate under it when a later + /// node replaces data. + /// + [Fact] + public void Start_clones_data_so_it_is_not_the_context_instance() + { + JsonNode ctx = JsonNode.Parse("""{ "a": 1 }""")!; + DslMessage message = DslMessage.Start(ctx); + + message.Data.Should().NotBeSameAs(message.Ctx); + } + + [Fact] + public void WithData_carries_ctx_meta_and_run_through() + { + DslMessage original = Sample(); + DslMessage next = original.WithData(JsonNode.Parse("""{ "total": 1 }""")); + + next.Ctx.Should().BeSameAs(original.Ctx); + next.Meta.Should().BeSameAs(original.Meta); + next.Run.Should().BeSameAs(original.Run); + next.Data!.ToJsonString().Should().Contain("\"total\":1"); + } + + [Fact] + public void Ctx_survives_an_arbitrary_number_of_hops() + { + DslMessage message = Sample(); + for (int i = 0; i < 25; i++) + { + message = message.WithData(JsonNode.Parse($$"""{ "step": {{i}} }""")); + } + + AbExValue value = AbExEvaluator.Evaluate( + AbExParser.ParseOrThrow("$ctx.orderId"), message.ToExpressionContext(message.Run)); + + value.AsString.Should().Be("ORD-1"); + } + + [Fact] + public void Run_metadata_exposes_the_documented_fields() + { + JsonObject run = DslMessage.RunMetadata( + "inst", "tenant", "wf", "2.0.0", 3, 4, DateTimeOffset.Parse("2026-08-17T10:11:12Z")); + + run["instanceId"]!.GetValue().Should().Be("inst"); + run["tenantId"]!.GetValue().Should().Be("tenant"); + run["workflow"]!.GetValue().Should().Be("wf"); + run["version"]!.GetValue().Should().Be("2.0.0"); + run["attempt"]!.GetValue().Should().Be(3); + run["superstep"]!.GetValue().Should().Be(4); + run["now"]!.GetValue().Should().StartWith("2026-08-17"); + } + + // ---- templates ------------------------------------------------------------------------ + + private static string Render(string template, DslMessage message) + => TemplateEngine.Render(template, TemplateBindings.From(message)); + + [Theory] + [InlineData("{{ $ctx.orderId }}", "ORD-1")] + [InlineData("{{ $.total }}", "429.5")] + [InlineData("{{ $.status }}", "settled")] + [InlineData("{{ $run.instanceId }}", "inst-1")] + [InlineData("{{ $run.attempt }}", "1")] + public void Templates_resolve_every_root(string template, string expected) + => Render(template, Sample()).Should().Be(expected); + + [Fact] + public void Templates_evaluate_full_expressions() + => Render("{{ $.total * 2 }}", Sample()).Should().Be("859"); + + [Fact] + public void Templates_interpolate_into_surrounding_text() + => Render("order {{ $ctx.orderId }} totals {{ $.total }} USD", Sample()) + .Should().Be("order ORD-1 totals 429.5 USD"); + + [Fact] + public void Templates_build_json_bodies() + => Render("""{"order":"{{ $ctx.orderId }}","amount":{{ $.total }}}""", Sample()) + .Should().Be("""{"order":"ORD-1","amount":429.5}"""); + + [Theory] + [InlineData("{{ $.missing }}")] + [InlineData("{{ $ctx.missing }}")] + [InlineData("{{ $.a.b.c }}")] + public void Absent_placeholders_render_empty(string template) + => Render(template, Sample()).Should().BeEmpty(); + + [Fact] + public void A_malformed_placeholder_renders_empty_rather_than_throwing() + { + Action act = () => Render("{{ $.a = 1 }}", Sample()); + act.Should().NotThrow(); + Render("x{{ $.a = 1 }}y", Sample()).Should().Be("xy"); + } + + [Fact] + public void A_template_with_no_placeholders_is_returned_unchanged() + => Render("https://ledger.internal/v1/settlements", Sample()) + .Should().Be("https://ledger.internal/v1/settlements"); + + [Fact] + public void Unterminated_placeholders_are_emitted_verbatim() + => Render("a {{ $.total", Sample()).Should().Be("a {{ $.total"); + + /// + /// The hook is opt-in. A plain POCO context must still resolve by dotted path exactly as it did + /// before ITemplateBindingSource existed. + /// + [Fact] + public void Non_dsl_messages_still_resolve_by_dotted_path() + { + var poco = new { Invoice = new { Id = "INV-7" } }; + + TemplateEngine.Render("{{ context.Invoice.Id }}", TemplateBindings.From(poco)) + .Should().Be("INV-7"); + TemplateEngine.Render("{{ Invoice.Id }}", TemplateBindings.From(poco)) + .Should().Be("INV-7"); + } +} From 338fddad7942f873750931c3718991a0006f7ba6 Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 21:38:21 +0100 Subject: [PATCH 4/8] feat(dsl): phase 2 - document model and two-phase validation Text in, typed model and pointer-accurate diagnostics out. Validation runs in two phases because one cannot do the job. JSON Schema checks shape - required properties, kind-discriminated variants, id and SemVer patterns. It cannot compare two array items, follow a reference, walk a graph, or parse a sub-language, so the semantic validator handles id uniqueness, edge endpoints, reachability, cycles, expressions, gates, catalog resolution and limits. Twenty-two stable codes, each with a test asserting its code, pointer and severity. The phases stop where continuing would be noise: a document that fails the schema is not read into the model, because reporting forty type errors from a half-understood document buries the one that matters. Two rules are worth calling out. A cycle is refused only when nothing on it yields - polling and wait-and-recheck are legitimate, but a cycle of pure compute nodes is a hot spin that occupies a dispatcher until the lifetime cap. And a duplicate unconditional edge is refused while two conditional edges between the same pair are fine, because that is exactly how a branch with a fallback is written. Environment-dependent checks - custom node registration, parameter schemas, egress hosts, hash conflicts - are reported as skipped rather than passed when there is no host to check against. A check that silently did not run is worse than one that openly did not, because only the second can be acted on. Document identity is a canonical SHA-256 (RFC 8785 JCS). Reformatting and property reordering do not change it; one byte of behaviour does. That makes the immutability rule enforceable and answers the operational question directly: is this instance running the document I am looking at? The schema is embedded from docs/schema/ rather than copied, with a test asserting the embedded resource matches the published file - otherwise an editor validates against one document and the host enforces another. One bug found by its own tests: the validator crashed on duplicate node ids, which is one of the things it exists to report. Building the kind lookup with ToDictionary threw before the diagnostic could be produced. Now built tolerantly, with robustness tests over pathological documents. 317 tests (+110). --- .../07-workflow-dsl-implementation-plan.md | 2 +- src/Abacus.Run.Dsl/Model/DslDocument.cs | 143 ++++ src/Abacus.Run.Dsl/Model/DslDocumentReader.cs | 410 +++++++++++ src/Abacus.Run.Dsl/Model/DslNode.cs | 212 ++++++ .../Validation/DslCanonicalHash.cs | 163 +++++ .../Validation/DslDiagnostic.cs | 101 +++ .../Validation/DslEnvironment.cs | 43 ++ src/Abacus.Run.Dsl/Validation/DslParser.cs | 126 ++++ .../Validation/DslSchemaValidator.cs | 107 +++ .../Validation/DslSemanticValidator.cs | 618 ++++++++++++++++ .../DslDocumentReaderTests.cs | 213 ++++++ tests/Abacus.Run.DslTests/DslFixtures.cs | 122 +++ .../DslHashAndSchemaTests.cs | 207 ++++++ .../Abacus.Run.DslTests/DslValidationTests.cs | 692 ++++++++++++++++++ 14 files changed, 3158 insertions(+), 1 deletion(-) create mode 100644 src/Abacus.Run.Dsl/Model/DslDocument.cs create mode 100644 src/Abacus.Run.Dsl/Model/DslDocumentReader.cs create mode 100644 src/Abacus.Run.Dsl/Model/DslNode.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslCanonicalHash.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslDiagnostic.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslEnvironment.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslParser.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslSchemaValidator.cs create mode 100644 src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs create mode 100644 tests/Abacus.Run.DslTests/DslDocumentReaderTests.cs create mode 100644 tests/Abacus.Run.DslTests/DslFixtures.cs create mode 100644 tests/Abacus.Run.DslTests/DslHashAndSchemaTests.cs create mode 100644 tests/Abacus.Run.DslTests/DslValidationTests.cs diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md index ffd9980..84c478e 100644 --- a/docs/implementation/07-workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -8,7 +8,7 @@ workflow behaves; the DSL is a second front end onto the runtime that already ex | # | Phase | Delivers | Depends on | Status | | - | ----- | -------- | ---------- | ------ | | 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ✅ Done — 207 tests | -| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ⬜ Not started | +| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ✅ Done — 317 tests | | 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ⬜ Not started | | 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ⬜ Not started | | 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ⬜ Not started | diff --git a/src/Abacus.Run.Dsl/Model/DslDocument.cs b/src/Abacus.Run.Dsl/Model/DslDocument.cs new file mode 100644 index 0000000..37597cb --- /dev/null +++ b/src/Abacus.Run.Dsl/Model/DslDocument.cs @@ -0,0 +1,143 @@ +using System.Text.Json.Nodes; + +namespace Abacus.Run.Dsl.Model; + +/// A parsed, structurally valid DSL document. +/// +/// Every element carries the JSON Pointer it was read from. Positions are built in rather than +/// retrofitted, because a validator that cannot say where is a validator people stop using. +/// +public sealed record DslDocument +{ + /// Media identifier, e.g. abacus.workflow/1.0. + public required string Dsl { get; init; } + + public required string Name { get; init; } + public required string Version { get; init; } + public string? Description { get; init; } + + /// JSON Schema the start payload must satisfy. + public JsonNode? ContextSchema { get; init; } + + public JsonNode? ResultSchema { get; init; } + + /// Enforce declared node input/output schemas at run time. + public bool Strict { get; init; } + + public required string Start { get; init; } + public IReadOnlyList Output { get; init; } = []; + public IReadOnlyList Nodes { get; init; } = []; + public IReadOnlyList Edges { get; init; } = []; + public IReadOnlyList Triggers { get; init; } = []; + public DslNotifications? Notifications { get; init; } + public IReadOnlyList OnFailure { get; init; } = []; + public DslAudit? Audit { get; init; } + public DslLimits Limits { get; init; } = new(); + + /// Canonical SHA-256 of the source document. Identity for the immutability rule. + public string Hash { get; init; } = string.Empty; + + /// Major version of , used to decide interpreter compatibility. + public int MajorVersion + { + get + { + int slash = Dsl.LastIndexOf('/'); + if (slash < 0 || slash + 1 >= Dsl.Length) + { + return -1; + } + + string version = Dsl[(slash + 1)..]; + int dot = version.IndexOf('.'); + return int.TryParse(dot < 0 ? version : version[..dot], out int major) ? major : -1; + } + } + + public DslNode? FindNode(string id) + => Nodes.FirstOrDefault(n => string.Equals(n.Id, id, StringComparison.Ordinal)); +} + +/// Approval gate configuration on one node. +public sealed record DslGate +{ + public required string Mode { get; init; } + public string? When { get; init; } + public string? Reason { get; init; } + public IReadOnlyList AssignTo { get; init; } = []; + public int RequireApprovers { get; init; } = 1; + public TimeSpan ExpiresAfter { get; init; } = TimeSpan.FromHours(24); + public string OnExpiryAction { get; init; } = "deadStop"; + public IReadOnlyList EscalateTo { get; init; } = []; + public bool AllowModification { get; init; } + public bool RequireSegregationOfDuties { get; init; } + public bool Locked { get; init; } + public string Pointer { get; init; } = string.Empty; +} + +/// A workflow-defined notification a node emits after it succeeds. +public sealed record DslNotify +{ + public required string Name { get; init; } + public IReadOnlyDictionary Payload { get; init; } = + new Dictionary(StringComparer.Ordinal); + public string Pointer { get; init; } = string.Empty; +} + +/// +/// One edge. From with several entries is a fan-in barrier; To with several is a +/// fan-out. Both are modelled as lists so the graph builder reads one shape. +/// +public sealed record DslEdge +{ + public IReadOnlyList From { get; init; } = []; + public IReadOnlyList To { get; init; } = []; + public string? When { get; init; } + public string? Select { get; init; } + public string? Label { get; init; } + public bool Idempotent { get; init; } + public string Pointer { get; init; } = string.Empty; + + public bool IsBarrier => From.Count > 1; + public bool IsFanOut => To.Count > 1; +} + +public sealed record DslTrigger +{ + public required string Topic { get; init; } + public string? CorrelationKey { get; init; } + public string? ContextFrom { get; init; } + public string Pointer { get; init; } = string.Empty; +} + +public sealed record DslNotifications +{ + public string Level { get; init; } = "standard"; + public bool Stream { get; init; } = true; + public IReadOnlyDictionary ByNode { get; init; } = + new Dictionary(StringComparer.Ordinal); + public IReadOnlyList Emits { get; init; } = []; + public string Pointer { get; init; } = string.Empty; +} + +public sealed record DslFailureRule +{ + public string? Exception { get; init; } + public string? Status { get; init; } + public string? Node { get; init; } + public required string Disposition { get; init; } + public string Pointer { get; init; } = string.Empty; +} + +public sealed record DslAudit +{ + public string? Key { get; init; } + public IReadOnlyList Sections { get; init; } = []; + public string Pointer { get; init; } = string.Empty; +} + +public sealed record DslLimits +{ + public int MaxAttempts { get; init; } = 5; + public int? MaxLifetimeHours { get; init; } +} diff --git a/src/Abacus.Run.Dsl/Model/DslDocumentReader.cs b/src/Abacus.Run.Dsl/Model/DslDocumentReader.cs new file mode 100644 index 0000000..788a519 --- /dev/null +++ b/src/Abacus.Run.Dsl/Model/DslDocumentReader.cs @@ -0,0 +1,410 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using System.Xml; +using Abacus.Run.Dsl.Validation; + +namespace Abacus.Run.Dsl.Model; + +/// +/// Builds the typed model from a document that has already passed schema validation. +/// +/// +/// Hand-written rather than driven by System.Text.Json converters, for one reason: every +/// element has to know the JSON Pointer it came from, and a converter cannot see where in the +/// document it is being invoked. +/// +public static class DslDocumentReader +{ + public static DslDocument Read(JsonNode document) + { + ArgumentNullException.ThrowIfNull(document); + + var root = (JsonObject)document; + + return new DslDocument + { + Dsl = Str(root, "dsl") ?? string.Empty, + Name = Str(root, "name") ?? string.Empty, + Version = Str(root, "version") ?? string.Empty, + Description = Str(root, "description"), + ContextSchema = root["context"]?.DeepClone(), + ResultSchema = root["result"]?.DeepClone(), + Strict = Bool(root, "strict") ?? false, + Start = Str(root, "start") ?? string.Empty, + Output = StrList(root["output"]), + Nodes = ReadNodes(root["nodes"] as JsonArray), + Edges = ReadEdges(root["edges"] as JsonArray), + Triggers = ReadTriggers(root["triggers"] as JsonArray), + Notifications = ReadNotifications(root["notifications"] as JsonObject), + OnFailure = ReadFailureRules(root["onFailure"] as JsonArray), + Audit = ReadAudit(root["audit"] as JsonObject), + Limits = ReadLimits(root["limits"] as JsonObject), + Hash = DslCanonicalHash.Compute(document) + }; + } + + // ---- nodes ---------------------------------------------------------------------------- + + private static IReadOnlyList ReadNodes(JsonArray? array) + { + if (array is null) + { + return []; + } + + var nodes = new List(array.Count); + + for (int i = 0; i < array.Count; i++) + { + if (array[i] is JsonObject node) + { + nodes.Add(ReadNode(node, $"/nodes/{i}")); + } + } + + return nodes; + } + + private static DslNode ReadNode(JsonObject node, string pointer) + { + string id = Str(node, "id") ?? string.Empty; + string kind = Str(node, "kind") ?? string.Empty; + + DslNode built = kind switch + { + DslNodeKinds.Transform => new DslTransformNode + { + Id = id, + Kind = kind, + Set = StrMap(node["set"] as JsonObject), + Replace = Bool(node, "replace") ?? false + }, + + DslNodeKinds.Http => new DslHttpNode + { + Id = id, + Kind = kind, + Method = Str(node, "method") ?? "GET", + Url = Str(node, "url") ?? string.Empty, + Headers = StrMap(node["headers"] as JsonObject), + Body = Str(node, "body"), + TimeoutSeconds = Int(node, "timeoutSeconds") ?? 30, + SuccessCodes = IntList(node["successCodes"]), + AllowedHosts = StrList(node["allowedHosts"]), + SendIdempotencyKey = Bool(node, "sendIdempotencyKey") ?? true + }, + + DslNodeKinds.Llm => new DslLlmNode + { + Id = id, + Kind = kind, + Model = Str(node, "model") ?? string.Empty, + System = Str(node, "system"), + Prompt = Str(node, "prompt") ?? string.Empty, + PromptVersion = Str(node, "promptVersion"), + StructuredOutput = node["structuredOutput"]?.DeepClone(), + Temperature = (float?)Decimal(node, "temperature"), + MaxTokens = Int(node, "maxTokens"), + StreamDeltas = Bool(node, "streamDeltas") ?? false, + EmitCompletion = Bool(node, "emitCompletion") ?? true + }, + + DslNodeKinds.Delay => new DslDelayNode + { + Id = id, + Kind = kind, + For = Duration(Str(node, "for")) ?? TimeSpan.Zero + }, + + DslNodeKinds.Approval => new DslApprovalNode { Id = id, Kind = kind }, + + DslNodeKinds.Publish => new DslPublishNode + { + Id = id, + Kind = kind, + Topic = Str(node, "topic") ?? string.Empty, + Payload = StrMap(node["payload"] as JsonObject), + CorrelationKey = Str(node, "correlationKey"), + Scope = Str(node, "scope") ?? "local" + }, + + DslNodeKinds.WaitEvent => new DslWaitEventNode + { + Id = id, + Kind = kind, + Topic = Str(node, "topic") ?? string.Empty, + CorrelationKey = Str(node, "correlationKey"), + Timeout = Duration(Str(node, "timeout")), + OnExpiry = Str(node, "onExpiry") ?? "deadStop" + }, + + DslNodeKinds.FanIn => new DslFanInNode + { + Id = id, + Kind = kind, + Into = Str(node, "into") ?? "items" + }, + + DslNodeKinds.Custom => new DslCustomNode + { + Id = id, + Kind = kind, + NodeName = Str(node, "node") ?? string.Empty, + With = node["with"]?.DeepClone() + }, + + // Unreachable through a schema-validated document; modelled rather than thrown so a + // future kind added to the schema before the reader fails as a diagnostic, not a crash. + _ => new DslApprovalNode { Id = id, Kind = kind } + }; + + return built with + { + Pointer = pointer, + Description = Str(node, "description"), + InputSchema = node["input"]?.DeepClone(), + OutputSchema = node["output"]?.DeepClone(), + Gate = ReadGate(node["gate"] as JsonObject, $"{pointer}/gate"), + Notify = ReadNotify(node["notify"] as JsonObject, $"{pointer}/notify") + }; + } + + private static DslGate? ReadGate(JsonObject? gate, string pointer) + { + if (gate is null) + { + return null; + } + + var expiry = gate["onExpiry"] as JsonObject; + + return new DslGate + { + Mode = Str(gate, "mode") ?? "requireApproval", + When = Str(gate, "when"), + Reason = Str(gate, "reason"), + AssignTo = StrList(gate["assignTo"]), + RequireApprovers = Int(gate, "requireApprovers") ?? 1, + ExpiresAfter = Duration(Str(gate, "expiresAfter")) ?? TimeSpan.FromHours(24), + OnExpiryAction = expiry is null ? "deadStop" : Str(expiry, "action") ?? "deadStop", + EscalateTo = expiry is null ? [] : StrList(expiry["assignTo"]), + AllowModification = Bool(gate, "allowModification") ?? false, + RequireSegregationOfDuties = Bool(gate, "requireSegregationOfDuties") ?? false, + Locked = Bool(gate, "locked") ?? false, + Pointer = pointer + }; + } + + private static DslNotify? ReadNotify(JsonObject? notify, string pointer) + => notify is null + ? null + : new DslNotify + { + Name = Str(notify, "name") ?? string.Empty, + Payload = StrMap(notify["payload"] as JsonObject), + Pointer = pointer + }; + + // ---- edges and the rest --------------------------------------------------------------- + + private static IReadOnlyList ReadEdges(JsonArray? array) + { + if (array is null) + { + return []; + } + + var edges = new List(array.Count); + + for (int i = 0; i < array.Count; i++) + { + if (array[i] is not JsonObject edge) + { + continue; + } + + edges.Add(new DslEdge + { + From = OneOrMany(edge["from"]), + To = OneOrMany(edge["to"]), + When = Str(edge, "when"), + Select = Str(edge, "select"), + Label = Str(edge, "label"), + Idempotent = Bool(edge, "idempotent") ?? false, + Pointer = $"/edges/{i}" + }); + } + + return edges; + } + + private static IReadOnlyList ReadTriggers(JsonArray? array) + { + if (array is null) + { + return []; + } + + var triggers = new List(array.Count); + + for (int i = 0; i < array.Count; i++) + { + if (array[i] is not JsonObject trigger) + { + continue; + } + + triggers.Add(new DslTrigger + { + Topic = Str(trigger, "topic") ?? string.Empty, + CorrelationKey = Str(trigger, "correlationKey"), + ContextFrom = Str(trigger, "contextFrom"), + Pointer = $"/triggers/{i}" + }); + } + + return triggers; + } + + private static DslNotifications? ReadNotifications(JsonObject? notifications) + => notifications is null + ? null + : new DslNotifications + { + Level = Str(notifications, "level") ?? "standard", + Stream = Bool(notifications, "stream") ?? true, + ByNode = StrMap(notifications["byNode"] as JsonObject), + Emits = StrList(notifications["emits"]), + Pointer = "/notifications" + }; + + private static IReadOnlyList ReadFailureRules(JsonArray? array) + { + if (array is null) + { + return []; + } + + var rules = new List(array.Count); + + for (int i = 0; i < array.Count; i++) + { + if (array[i] is not JsonObject rule) + { + continue; + } + + var match = rule["match"] as JsonObject; + + rules.Add(new DslFailureRule + { + Exception = match is null ? null : Str(match, "exception"), + Status = match is null ? null : Str(match, "status"), + Node = match is null ? null : Str(match, "node"), + Disposition = Str(rule, "disposition") ?? "retry", + Pointer = $"/onFailure/{i}" + }); + } + + return rules; + } + + private static DslAudit? ReadAudit(JsonObject? audit) + => audit is null + ? null + : new DslAudit + { + Key = Str(audit, "key"), + Sections = StrList(audit["sections"]), + Pointer = "/audit" + }; + + private static DslLimits ReadLimits(JsonObject? limits) + => limits is null + ? new DslLimits() + : new DslLimits + { + MaxAttempts = Int(limits, "maxAttempts") ?? 5, + MaxLifetimeHours = Int(limits, "maxLifetimeHours") + }; + + // ---- primitives ----------------------------------------------------------------------- + + private static string? Str(JsonObject obj, string name) + => obj[name] is JsonValue value && value.TryGetValue(out string? s) ? s : null; + + private static bool? Bool(JsonObject obj, string name) + => obj[name] is JsonValue value && value.TryGetValue(out bool b) ? b : null; + + private static int? Int(JsonObject obj, string name) + => obj[name] is JsonValue value && value.TryGetValue(out int i) ? i : null; + + private static decimal? Decimal(JsonObject obj, string name) + => obj[name] is JsonValue value && value.TryGetValue(out decimal d) ? d : null; + + private static IReadOnlyList StrList(JsonNode? node) + => node is JsonArray array + ? array.Select(n => n?.GetValue()).Where(s => s is not null).Select(s => s!).ToArray() + : []; + + private static IReadOnlyList IntList(JsonNode? node) + => node is JsonArray array + ? array.Where(n => n is not null).Select(n => n!.GetValue()).ToArray() + : []; + + private static IReadOnlyDictionary StrMap(JsonObject? obj) + { + if (obj is null) + { + return new Dictionary(StringComparer.Ordinal); + } + + var map = new Dictionary(StringComparer.Ordinal); + + foreach ((string key, JsonNode? value) in obj) + { + if (value is JsonValue jsonValue && jsonValue.TryGetValue(out string? text) && text is not null) + { + map[key] = text; + } + } + + return map; + } + + /// Normalises the two edge shapes — one endpoint or several — into one list. + private static IReadOnlyList OneOrMany(JsonNode? node) => node switch + { + JsonArray array => StrList(array), + JsonValue value when value.TryGetValue(out string? s) && s is not null => [s], + _ => [] + }; + + /// + /// ISO-8601 duration. The schema has already checked the syntax, so a failure here means the + /// pattern and this parser disagree — treated as absent rather than thrown, and caught by the + /// semantic validator's own range checks. + /// + internal static TimeSpan? Duration(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + try + { + return XmlConvert.ToTimeSpan(value); + } + catch (FormatException) + { + return null; + } + } + + internal static string ToIso8601(TimeSpan value) + => XmlConvert.ToString(value); + + internal static string Number(decimal value) + => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Abacus.Run.Dsl/Model/DslNode.cs b/src/Abacus.Run.Dsl/Model/DslNode.cs new file mode 100644 index 0000000..c6ec7e1 --- /dev/null +++ b/src/Abacus.Run.Dsl/Model/DslNode.cs @@ -0,0 +1,212 @@ +using System.Text.Json.Nodes; + +namespace Abacus.Run.Dsl.Model; + +/// The kind discriminator values the interpreter understands. +public static class DslNodeKinds +{ + public const string Transform = "transform"; + public const string Http = "http"; + public const string Llm = "llm"; + public const string Delay = "delay"; + public const string Approval = "approval"; + public const string Publish = "publish"; + public const string WaitEvent = "wait-event"; + public const string FanIn = "fan-in"; + public const string Custom = "custom"; + + public static IReadOnlyList All { get; } = + [Transform, Http, Llm, Delay, Approval, Publish, WaitEvent, FanIn, Custom]; + + /// + /// Whether a node of this kind may carry an approval gate. fan-in cannot, mirroring + /// RawNode in the compiled API: a barrier target aggregates messages that already + /// happened, so pausing it would gate nothing that has not already run. + /// + public static bool IsGateable(string kind) => kind != FanIn; +} + +/// One node of the graph, as the document declared it. +public abstract record DslNode +{ + public required string Id { get; init; } + public required string Kind { get; init; } + public string? Description { get; init; } + public JsonNode? InputSchema { get; init; } + public JsonNode? OutputSchema { get; init; } + public DslGate? Gate { get; init; } + public DslNotify? Notify { get; init; } + + /// JSON Pointer this node was read from, e.g. /nodes/3. + public string Pointer { get; init; } = string.Empty; + + /// Every AbEx expression this node declares, with the pointer that located it. + public virtual IEnumerable<(string Pointer, string Expression)> Expressions() + { + if (Gate?.When is { Length: > 0 } when) + { + yield return ($"{Gate.Pointer}/when", when); + } + + if (Notify is not null) + { + foreach ((string key, string expression) in Notify.Payload) + { + yield return ($"{Notify.Pointer}/payload/{JsonPointer.Escape(key)}", expression); + } + } + } + + /// Every {{ }} template this node declares. + public virtual IEnumerable<(string Pointer, string Template)> Templates() => []; +} + +public sealed record DslTransformNode : DslNode +{ + public IReadOnlyDictionary Set { get; init; } = + new Dictionary(StringComparer.Ordinal); + + /// Replace data outright rather than merging into it. + public bool Replace { get; init; } + + public override IEnumerable<(string Pointer, string Expression)> Expressions() + { + foreach ((string pointer, string expression) in base.Expressions()) + { + yield return (pointer, expression); + } + + foreach ((string target, string expression) in Set) + { + yield return ($"{Pointer}/set/{JsonPointer.Escape(target)}", expression); + } + } +} + +public sealed record DslHttpNode : DslNode +{ + public string Method { get; init; } = "GET"; + public required string Url { get; init; } + public IReadOnlyDictionary Headers { get; init; } = + new Dictionary(StringComparer.Ordinal); + public string? Body { get; init; } + public int TimeoutSeconds { get; init; } = 30; + public IReadOnlyList SuccessCodes { get; init; } = []; + public IReadOnlyList AllowedHosts { get; init; } = []; + public bool SendIdempotencyKey { get; init; } = true; + + public override IEnumerable<(string Pointer, string Template)> Templates() + { + yield return ($"{Pointer}/url", Url); + + if (Body is { Length: > 0 }) + { + yield return ($"{Pointer}/body", Body); + } + + foreach ((string header, string value) in Headers) + { + yield return ($"{Pointer}/headers/{JsonPointer.Escape(header)}", value); + } + } +} + +public sealed record DslLlmNode : DslNode +{ + public required string Model { get; init; } + public string? System { get; init; } + public required string Prompt { get; init; } + public string? PromptVersion { get; init; } + public JsonNode? StructuredOutput { get; init; } + public float? Temperature { get; init; } + public int? MaxTokens { get; init; } + public bool StreamDeltas { get; init; } + public bool EmitCompletion { get; init; } = true; + + public override IEnumerable<(string Pointer, string Template)> Templates() + { + yield return ($"{Pointer}/prompt", Prompt); + + if (System is { Length: > 0 }) + { + yield return ($"{Pointer}/system", System); + } + } +} + +public sealed record DslDelayNode : DslNode +{ + public TimeSpan For { get; init; } +} + +public sealed record DslApprovalNode : DslNode; + +public sealed record DslPublishNode : DslNode +{ + public required string Topic { get; init; } + public IReadOnlyDictionary Payload { get; init; } = + new Dictionary(StringComparer.Ordinal); + public string? CorrelationKey { get; init; } + public string Scope { get; init; } = "local"; + + public override IEnumerable<(string Pointer, string Expression)> Expressions() + { + foreach ((string pointer, string expression) in base.Expressions()) + { + yield return (pointer, expression); + } + + foreach ((string key, string expression) in Payload) + { + yield return ($"{Pointer}/payload/{JsonPointer.Escape(key)}", expression); + } + + if (CorrelationKey is { Length: > 0 }) + { + yield return ($"{Pointer}/correlationKey", CorrelationKey); + } + } +} + +public sealed record DslWaitEventNode : DslNode +{ + public required string Topic { get; init; } + public string? CorrelationKey { get; init; } + public TimeSpan? Timeout { get; init; } + public string OnExpiry { get; init; } = "deadStop"; + + public override IEnumerable<(string Pointer, string Expression)> Expressions() + { + foreach ((string pointer, string expression) in base.Expressions()) + { + yield return (pointer, expression); + } + + if (CorrelationKey is { Length: > 0 }) + { + yield return ($"{Pointer}/correlationKey", CorrelationKey); + } + } +} + +public sealed record DslFanInNode : DslNode +{ + /// Path within data that receives the aggregated array. + public string Into { get; init; } = "items"; +} + +public sealed record DslCustomNode : DslNode +{ + /// Name registered via AddDslNode. + public required string NodeName { get; init; } + + public JsonNode? With { get; init; } +} + +/// RFC 6901 pointer escaping. Two characters, and getting them wrong misplaces a caret. +public static class JsonPointer +{ + public static string Escape(string segment) + => segment.Replace("~", "~0", StringComparison.Ordinal) + .Replace("/", "~1", StringComparison.Ordinal); +} diff --git a/src/Abacus.Run.Dsl/Validation/DslCanonicalHash.cs b/src/Abacus.Run.Dsl/Validation/DslCanonicalHash.cs new file mode 100644 index 0000000..2a50473 --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslCanonicalHash.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace Abacus.Run.Dsl.Validation; + +/// +/// Canonical SHA-256 of a document, following RFC 8785 (JCS): properties sorted by code unit, +/// no insignificant whitespace, numbers in shortest round-trip form. +/// +/// +/// This is what makes the immutability rule enforceable. A published (name, version) is fixed; +/// reformatting a document must not look like a change, and changing one byte of behaviour must not +/// look like the same document. It also answers the operational question directly: is this instance +/// running the document I am looking at? +/// +public static class DslCanonicalHash +{ + public static string Compute(JsonNode? document) + { + var builder = new StringBuilder(); + Write(document, builder); + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexStringLower(hash); + } + + /// The canonical text itself, exposed because a hash mismatch is easier to diagnose with it. + public static string Canonicalize(JsonNode? document) + { + var builder = new StringBuilder(); + Write(document, builder); + return builder.ToString(); + } + + private static void Write(JsonNode? node, StringBuilder builder) + { + switch (node) + { + case null: + builder.Append("null"); + return; + + case JsonObject obj: + { + builder.Append('{'); + bool first = true; + + // Ordinal ordering is the specification's requirement, and it is also what makes the + // hash stable across writers that preserve source order differently. + foreach (KeyValuePair property in + obj.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + if (!first) + { + builder.Append(','); + } + + first = false; + WriteString(property.Key, builder); + builder.Append(':'); + Write(property.Value, builder); + } + + builder.Append('}'); + return; + } + + case JsonArray array: + { + builder.Append('['); + for (int i = 0; i < array.Count; i++) + { + if (i > 0) + { + builder.Append(','); + } + + Write(array[i], builder); + } + + builder.Append(']'); + return; + } + + case JsonValue value: + { + if (value.TryGetValue(out bool b)) + { + builder.Append(b ? "true" : "false"); + return; + } + + if (value.TryGetValue(out string? s) && s is not null) + { + WriteString(s, builder); + return; + } + + if (value.TryGetValue(out decimal d)) + { + builder.Append(FormatNumber(d)); + return; + } + + if (value.TryGetValue(out double dbl)) + { + builder.Append(dbl.ToString("R", CultureInfo.InvariantCulture)); + return; + } + + builder.Append(value.ToJsonString()); + return; + } + + default: + builder.Append(node.ToJsonString()); + return; + } + } + + /// Shortest round-trip form: 1.50 and 1.5 hash alike, as JCS requires. + private static string FormatNumber(decimal value) + { + decimal normalized = value / 1.000000000000000000000000000000000m; + return normalized == decimal.Truncate(normalized) + ? decimal.Truncate(normalized).ToString(CultureInfo.InvariantCulture) + : normalized.ToString(CultureInfo.InvariantCulture); + } + + private static void WriteString(string value, StringBuilder builder) + { + builder.Append('"'); + + foreach (char c in value) + { + switch (c) + { + case '"': builder.Append("\\\""); break; + case '\\': builder.Append("\\\\"); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (c < 0x20) + { + builder.Append(CultureInfo.InvariantCulture, $"\\u{(int)c:x4}"); + } + else + { + builder.Append(c); + } + + break; + } + } + + builder.Append('"'); + } +} diff --git a/src/Abacus.Run.Dsl/Validation/DslDiagnostic.cs b/src/Abacus.Run.Dsl/Validation/DslDiagnostic.cs new file mode 100644 index 0000000..0c38675 --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslDiagnostic.cs @@ -0,0 +1,101 @@ +namespace Abacus.Run.Dsl.Validation; + +public enum DslSeverity +{ + Error, + Warning +} + +/// +/// One finding about a document, located precisely enough for an editor to underline it. +/// +/// +/// The pointer is not a nicety. A DSL without precise error locations is a DSL people abandon after +/// the third unhelpful failure, so every check is required to produce one. +/// +public sealed record DslDiagnostic( + string Code, + DslSeverity Severity, + string Pointer, + string Message, + string? Suggestion = null) +{ + public static DslDiagnostic Error(string code, string pointer, string message, string? suggestion = null) + => new(code, DslSeverity.Error, pointer, message, suggestion); + + public static DslDiagnostic Warning(string code, string pointer, string message, string? suggestion = null) + => new(code, DslSeverity.Warning, pointer, message, suggestion); + + public override string ToString() + { + string severity = Severity == DslSeverity.Error ? "error" : "warn "; + string location = string.IsNullOrEmpty(Pointer) ? "/" : Pointer; + string suffix = Suggestion is null ? string.Empty : $" {Suggestion}"; + return $"{Code} {severity} {location} {Message}{suffix}"; + } +} + +/// The outcome of validating one document. +public sealed record DslValidationResult( + IReadOnlyList Diagnostics, + IReadOnlyList SkippedChecks) +{ + public static DslValidationResult Empty { get; } = new([], []); + + public bool IsValid => !Diagnostics.Any(d => d.Severity == DslSeverity.Error); + + public IEnumerable Errors => Diagnostics.Where(d => d.Severity == DslSeverity.Error); + + public IEnumerable Warnings => Diagnostics.Where(d => d.Severity == DslSeverity.Warning); + + public bool Has(string code) => Diagnostics.Any(d => string.Equals(d.Code, code, StringComparison.Ordinal)); + + public string Describe() => Diagnostics.Count == 0 + ? "No diagnostics." + : string.Join(Environment.NewLine, Diagnostics.Select(d => d.ToString())); +} + +/// +/// The stable diagnostic codes. Stable because they end up in logs, editor configuration and support +/// conversations, so renaming one is a breaking change to something other than code. +/// +public static class DslCodes +{ + // 01xx — document identity + public const string UnsupportedDslVersion = "DSL0101"; + public const string HashConflict = "DSL0102"; + public const string MalformedJson = "DSL0103"; + public const string SchemaViolation = "DSL0104"; + + // 02xx — references + public const string DuplicateNodeId = "DSL0201"; + public const string StartNotFound = "DSL0202"; + public const string OutputNotFound = "DSL0203"; + public const string EdgeEndpointNotFound = "DSL0207"; + public const string DuplicateEdge = "DSL0208"; + + // 03xx — graph shape + public const string UnreachableNode = "DSL0301"; + public const string DeadEndNode = "DSL0302"; + public const string TightCycle = "DSL0303"; + public const string BarrierSourceUnreachable = "DSL0304"; + + // 04xx — expressions + public const string ExpressionParseError = "DSL0401"; + public const string UnknownFunction = "DSL0412"; + public const string NonDeterministicCondition = "DSL0413"; + public const string ExpressionTooDeep = "DSL0414"; + + // 05xx — gates + public const string GateOnNonGateableKind = "DSL0501"; + public const string ConditionalGateWithoutPredicate = "DSL0502"; + public const string EscalationWithoutAssignees = "DSL0503"; + + // 06xx — environment + public const string UnknownCustomNode = "DSL0601"; + public const string CustomNodeParameters = "DSL0602"; + public const string EgressHostsRequired = "DSL0603"; + + // 07xx — policy + public const string LimitExceeded = "DSL0701"; +} diff --git a/src/Abacus.Run.Dsl/Validation/DslEnvironment.cs b/src/Abacus.Run.Dsl/Validation/DslEnvironment.cs new file mode 100644 index 0000000..94f5d87 --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslEnvironment.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Nodes; + +namespace Abacus.Run.Dsl.Validation; + +/// Bounds a document. All configurable down, none up. +public sealed record DslPolicy +{ + public static DslPolicy Default { get; } = new(); + + public int MaxDocumentBytes { get; init; } = 1024 * 1024; + public int MaxNodes { get; init; } = 500; + public int MaxEdges { get; init; } = 2000; + public int MaxExpressionDepth { get; init; } = 32; + + /// Supported major versions of the dsl media identifier. + public IReadOnlyList SupportedMajorVersions { get; init; } = [1]; +} + +/// +/// What the host knows that a document alone cannot be checked against: which custom nodes are +/// registered, whether egress is enforced, and which (name, version) pairs are already +/// published. +/// +/// +/// Optional by design. Offline linting has no host, and the checks that need one are reported as +/// skipped rather than passed — a check that silently did not run is worse than one that +/// openly did not. +/// +public sealed record DslEnvironment +{ + /// Registered custom node names, mapped to the parameter schema each one publishes. + public IReadOnlyDictionary CustomNodes { get; init; } = + new Dictionary(StringComparer.Ordinal); + + /// Whether http nodes must declare an allow-list. + public bool EnforceEgress { get; init; } = true; + + /// Already-registered documents, keyed name@version, mapped to their hash. + public IReadOnlyDictionary PublishedHashes { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public DslPolicy Policy { get; init; } = DslPolicy.Default; +} diff --git a/src/Abacus.Run.Dsl/Validation/DslParser.cs b/src/Abacus.Run.Dsl/Validation/DslParser.cs new file mode 100644 index 0000000..7f8dbc2 --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslParser.cs @@ -0,0 +1,126 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Model; + +namespace Abacus.Run.Dsl.Validation; + +/// A parsed document, the diagnostics found on the way, or both. +public sealed record DslParseResult(DslDocument? Document, DslValidationResult Validation) +{ + public bool IsValid => Document is not null && Validation.IsValid; +} + +/// +/// The entry point: text in, model and diagnostics out. +/// +/// +/// The phases run in order and stop where continuing would be noise. A document that is not JSON +/// cannot be schema-checked; one that fails the schema cannot be read into the model, and reporting +/// forty type errors from a half-understood document buries the one that matters. +/// +public static class DslParser +{ + public static DslParseResult Parse(string text, DslEnvironment? environment = null) + { + ArgumentNullException.ThrowIfNull(text); + + DslPolicy policy = environment?.Policy ?? DslPolicy.Default; + + int bytes = Encoding.UTF8.GetByteCount(text); + if (bytes > policy.MaxDocumentBytes) + { + return Failed(DslDiagnostic.Error( + DslCodes.LimitExceeded, string.Empty, + $"The document is {bytes} bytes; the limit is {policy.MaxDocumentBytes}.")); + } + + JsonNode? node; + try + { + node = JsonNode.Parse(text, documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + } + catch (JsonException ex) + { + return Failed(DslDiagnostic.Error( + DslCodes.MalformedJson, string.Empty, $"The document is not valid JSON. {ex.Message}")); + } + + if (node is not JsonObject) + { + return Failed(DslDiagnostic.Error( + DslCodes.MalformedJson, string.Empty, "The document must be a JSON object.")); + } + + IReadOnlyList structural = DslSchemaValidator.Validate(node); + if (structural.Any(d => d.Severity == DslSeverity.Error)) + { + return new DslParseResult(null, new DslValidationResult(structural, [])); + } + + DslDocument document = DslDocumentReader.Read(node); + DslValidationResult semantic = DslSemanticValidator.Validate(document, environment); + + var diagnostics = new List(structural); + diagnostics.AddRange(semantic.Diagnostics); + + return new DslParseResult( + document, new DslValidationResult(diagnostics, semantic.SkippedChecks)); + } + + /// Parses a document from disk, naming the file in any diagnostic about reading it. + public static DslParseResult ParseFile(string path, DslEnvironment? environment = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + try + { + return Parse(File.ReadAllText(path), environment); + } + catch (IOException ex) + { + return Failed(DslDiagnostic.Error( + DslCodes.MalformedJson, string.Empty, $"Could not read '{path}'. {ex.Message}")); + } + catch (UnauthorizedAccessException ex) + { + return Failed(DslDiagnostic.Error( + DslCodes.MalformedJson, string.Empty, $"Could not read '{path}'. {ex.Message}")); + } + } + + /// + /// Parses or throws, for call sites that treat an invalid document as a startup failure. The + /// message carries every diagnostic, because the first one is rarely the only one worth fixing. + /// + public static DslDocument ParseOrThrow(string text, DslEnvironment? environment = null, string? source = null) + { + DslParseResult result = Parse(text, environment); + + if (result.IsValid) + { + return result.Document!; + } + + string where = source is null ? "The DSL document" : $"'{source}'"; + throw new DslValidationException( + $"{where} is not valid:{Environment.NewLine}{result.Validation.Describe()}", + result.Validation); + } + + private static DslParseResult Failed(DslDiagnostic diagnostic) + => new(null, new DslValidationResult([diagnostic], [])); +} + +/// Thrown when an invalid document reaches a call site that cannot carry on without one. +public sealed class DslValidationException : Exception +{ + public DslValidationException(string message, DslValidationResult validation) : base(message) + => Validation = validation; + + public DslValidationResult Validation { get; } +} diff --git a/src/Abacus.Run.Dsl/Validation/DslSchemaValidator.cs b/src/Abacus.Run.Dsl/Validation/DslSchemaValidator.cs new file mode 100644 index 0000000..1f6efaf --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslSchemaValidator.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using Json.Schema; + +namespace Abacus.Run.Dsl.Validation; + +/// +/// Phase 1 of validation: structural conformance to the published JSON Schema. +/// +/// +/// The schema is embedded from docs/schema/ rather than duplicated, so the document an editor +/// validates against and the one the host enforces are the same bytes. +/// +public static class DslSchemaValidator +{ + private const string ResourceName = "Abacus.Run.Dsl.Schema.abacus-workflow-dsl-1.0.json"; + + private static readonly Lazy SchemaTextValue = new(LoadSchemaText, isThreadSafe: true); + private static readonly Lazy Schema = new( + () => JsonSchema.FromText(SchemaTextValue.Value), isThreadSafe: true); + + /// The published schema, as served by GET /v2/dsl/schema. + public static string SchemaText => SchemaTextValue.Value; + + public static IReadOnlyList Validate(JsonNode? document) + { + if (document is null) + { + return [DslDiagnostic.Error(DslCodes.MalformedJson, string.Empty, "The document is empty.")]; + } + + EvaluationResults results = Schema.Value.Evaluate(document, new EvaluationOptions + { + OutputFormat = OutputFormat.List + }); + + if (results.IsValid) + { + return []; + } + + var diagnostics = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + Collect(results, diagnostics, seen); + + // A failing evaluation always has a reason, but a purely structural failure can report it + // only at the root; never return "invalid" with nothing to show for it. + if (diagnostics.Count == 0) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.SchemaViolation, string.Empty, "The document does not match the DSL schema.")); + } + + return diagnostics; + } + + private static void Collect(EvaluationResults results, List diagnostics, HashSet seen) + { + if (results.Errors is { Count: > 0 }) + { + string pointer = results.InstanceLocation.ToString(); + + foreach ((string keyword, string message) in results.Errors) + { + string text = Humanise(keyword, message); + if (seen.Add($"{pointer}|{text}")) + { + diagnostics.Add(DslDiagnostic.Error(DslCodes.SchemaViolation, pointer, text)); + } + } + } + + foreach (EvaluationResults detail in results.Details) + { + Collect(detail, diagnostics, seen); + } + } + + /// + /// Schema messages are written for schema authors. These are the three that a workflow author + /// meets most often, reworded to say what to do about them. + /// + private static string Humanise(string keyword, string message) => keyword switch + { + "required" => $"{message} (a required property is missing)", + "additionalProperties" => $"{message} (unknown property — check the spelling)", + "pattern" => $"{message} (the value does not match the required format)", + _ => message + }; + + private static string LoadSchemaText() + { + Assembly assembly = typeof(DslSchemaValidator).Assembly; + using Stream? stream = assembly.GetManifestResourceStream(ResourceName); + + if (stream is null) + { + throw new InvalidOperationException( + $"The DSL schema resource '{ResourceName}' is missing from {assembly.GetName().Name}. " + + "It is embedded from docs/schema/; check the EmbeddedResource item in the project file."); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs b/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs new file mode 100644 index 0000000..c1eeb5b --- /dev/null +++ b/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs @@ -0,0 +1,618 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Model; +using Json.Schema; + +namespace Abacus.Run.Dsl.Validation; + +/// +/// Phase 2 of validation: everything JSON Schema cannot express. +/// +/// +/// Each item here is a real way to write a structurally valid document that is nonsense — a duplicate +/// id, an edge to a node that does not exist, a cycle with nothing durable on it, a condition whose +/// value changes between the run and its resume. A schema compares a value to a rule; none of these +/// are about one value. +/// +public static class DslSemanticValidator +{ + public static DslValidationResult Validate(DslDocument document, DslEnvironment? environment = null) + { + ArgumentNullException.ThrowIfNull(document); + + var diagnostics = new List(); + var skipped = new List(); + DslPolicy policy = environment?.Policy ?? DslPolicy.Default; + + CheckVersion(document, policy, diagnostics); + CheckLimits(document, policy, diagnostics); + + var ids = CheckNodeIds(document, diagnostics); + + CheckReferences(document, ids, diagnostics); + CheckGraph(document, ids, diagnostics); + CheckExpressions(document, policy, diagnostics); + CheckGates(document, diagnostics); + CheckNotifications(document, ids, diagnostics); + CheckEnvironment(document, environment, diagnostics, skipped); + + return new DslValidationResult(diagnostics, skipped); + } + + // ---- identity and limits --------------------------------------------------------------- + + private static void CheckVersion(DslDocument document, DslPolicy policy, List diagnostics) + { + if (!policy.SupportedMajorVersions.Contains(document.MajorVersion)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.UnsupportedDslVersion, "/dsl", + $"'{document.Dsl}' is not a supported DSL version.", + $"This interpreter reads major version(s) {string.Join(", ", policy.SupportedMajorVersions)}.")); + } + } + + private static void CheckLimits(DslDocument document, DslPolicy policy, List diagnostics) + { + if (document.Nodes.Count > policy.MaxNodes) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.LimitExceeded, "/nodes", + $"The document declares {document.Nodes.Count} nodes; the limit is {policy.MaxNodes}.")); + } + + if (document.Edges.Count > policy.MaxEdges) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.LimitExceeded, "/edges", + $"The document declares {document.Edges.Count} edges; the limit is {policy.MaxEdges}.")); + } + } + + private static HashSet CheckNodeIds(DslDocument document, List diagnostics) + { + var ids = new HashSet(StringComparer.Ordinal); + + foreach (DslNode node in document.Nodes) + { + if (!ids.Add(node.Id)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.DuplicateNodeId, $"{node.Pointer}/id", + $"Node id '{node.Id}' is declared more than once.", + "Node ids key gate policy and node state; two nodes cannot share one.")); + } + } + + return ids; + } + + // ---- references ------------------------------------------------------------------------- + + private static void CheckReferences( + DslDocument document, HashSet ids, List diagnostics) + { + if (!ids.Contains(document.Start)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.StartNotFound, "/start", + $"'{document.Start}' is not a node.", Suggest(document.Start, ids))); + } + + for (int i = 0; i < document.Output.Count; i++) + { + if (!ids.Contains(document.Output[i])) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.OutputNotFound, $"/output/{i}", + $"'{document.Output[i]}' is not a node.", Suggest(document.Output[i], ids))); + } + } + + var unconditional = new HashSet(StringComparer.Ordinal); + + foreach (DslEdge edge in document.Edges) + { + for (int i = 0; i < edge.From.Count; i++) + { + if (!ids.Contains(edge.From[i])) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.EdgeEndpointNotFound, + edge.From.Count == 1 ? $"{edge.Pointer}/from" : $"{edge.Pointer}/from/{i}", + $"Edge starts at '{edge.From[i]}', which is not a node.", + Suggest(edge.From[i], ids))); + } + } + + for (int i = 0; i < edge.To.Count; i++) + { + if (!ids.Contains(edge.To[i])) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.EdgeEndpointNotFound, + edge.To.Count == 1 ? $"{edge.Pointer}/to" : $"{edge.Pointer}/to/{i}", + $"Edge targets '{edge.To[i]}', which is not a node.", + Suggest(edge.To[i], ids))); + } + } + + // Only unconditional duplicates are a problem: two conditional edges between the same + // pair is exactly how a branch with a fallback is written. + if (edge.When is null && edge.Select is null && !edge.Idempotent && !edge.IsBarrier) + { + foreach (string target in edge.To) + { + string key = $"{string.Join(",", edge.From)}->{target}"; + if (!unconditional.Add(key)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.DuplicateEdge, edge.Pointer, + $"A second unconditional edge already connects '{edge.From[0]}' to '{target}'.", + "Give one a 'when', or set 'idempotent': true if the repeat is intended.")); + } + } + } + } + } + + private static string? Suggest(string value, HashSet candidates) + { + string? best = null; + int bestDistance = int.MaxValue; + + foreach (string candidate in candidates) + { + int distance = AbExFunctions.EditDistance(value, candidate); + if (distance < bestDistance) + { + bestDistance = distance; + best = candidate; + } + } + + return best is not null && bestDistance <= Math.Max(2, value.Length / 3) + ? $"Did you mean '{best}'?" + : null; + } + + // ---- graph shape -------------------------------------------------------------------------- + + private static void CheckGraph( + DslDocument document, HashSet ids, List diagnostics) + { + Dictionary> adjacency = BuildAdjacency(document, ids); + + // Reachability + var reachable = new HashSet(StringComparer.Ordinal); + if (ids.Contains(document.Start)) + { + var queue = new Queue(); + queue.Enqueue(document.Start); + reachable.Add(document.Start); + + while (queue.Count > 0) + { + string current = queue.Dequeue(); + foreach (string next in adjacency.GetValueOrDefault(current, [])) + { + if (reachable.Add(next)) + { + queue.Enqueue(next); + } + } + } + + foreach (DslNode node in document.Nodes.Where(n => !reachable.Contains(n.Id))) + { + diagnostics.Add(DslDiagnostic.Warning( + DslCodes.UnreachableNode, node.Pointer, + $"Node '{node.Id}' is unreachable from '{document.Start}'.", + "It will never run. Add an edge to it, or remove it.")); + } + } + + // Dead ends: a node nothing leaves that is not declared as an output + var declaredOutputs = new HashSet(document.Output, StringComparer.Ordinal); + + foreach (DslNode node in document.Nodes) + { + bool hasOutgoing = adjacency.GetValueOrDefault(node.Id, []).Count > 0; + + if (!hasOutgoing && declaredOutputs.Count > 0 && !declaredOutputs.Contains(node.Id) && + reachable.Contains(node.Id)) + { + diagnostics.Add(DslDiagnostic.Warning( + DslCodes.DeadEndNode, node.Pointer, + $"Node '{node.Id}' has no outgoing edge and is not declared as an output.", + "A run reaching it stops there and produces no result.")); + } + } + + // Barrier sources must themselves be reachable, or the barrier never releases + foreach (DslEdge edge in document.Edges.Where(e => e.IsBarrier)) + { + foreach (string source in edge.From.Where(s => ids.Contains(s) && !reachable.Contains(s))) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.BarrierSourceUnreachable, edge.Pointer, + $"Barrier source '{source}' is unreachable, so the barrier can never release.", + $"'{string.Join("', '", edge.To)}' would wait forever.")); + } + } + + CheckCycles(document, adjacency, diagnostics); + } + + private static Dictionary> BuildAdjacency(DslDocument document, HashSet ids) + { + var adjacency = new Dictionary>(StringComparer.Ordinal); + + foreach (DslEdge edge in document.Edges) + { + foreach (string from in edge.From.Where(ids.Contains)) + { + foreach (string to in edge.To.Where(ids.Contains)) + { + if (!adjacency.TryGetValue(from, out List? targets)) + { + adjacency[from] = targets = []; + } + + if (!targets.Contains(to, StringComparer.Ordinal)) + { + targets.Add(to); + } + } + } + } + + return adjacency; + } + + /// + /// A cycle is legitimate — polling, retry-until, wait-and-recheck — but only if something on it + /// yields. A cycle of pure compute nodes is a hot spin that will occupy a dispatcher until the + /// instance hits its lifetime cap, so it is refused rather than warned about. + /// + private static void CheckCycles( + DslDocument document, Dictionary> adjacency, List diagnostics) + { + // Built tolerantly rather than with ToDictionary: duplicate ids are one of the things this + // validator exists to report, so it has to survive a document that has them. + var kinds = new Dictionary(StringComparer.Ordinal); + foreach (DslNode node in document.Nodes) + { + kinds.TryAdd(node.Id, node.Kind); + } + + var state = new Dictionary(StringComparer.Ordinal); // 0 unseen, 1 open, 2 closed + var stack = new List(); + var reported = new HashSet(StringComparer.Ordinal); + + foreach (DslNode node in document.Nodes) + { + if (state.GetValueOrDefault(node.Id) == 0) + { + Visit(node.Id); + } + } + + void Visit(string current) + { + state[current] = 1; + stack.Add(current); + + foreach (string next in adjacency.GetValueOrDefault(current, [])) + { + int colour = state.GetValueOrDefault(next); + + if (colour == 1) + { + int start = stack.IndexOf(next); + List cycle = stack[start..]; + + bool yields = cycle.Any(id => + kinds.GetValueOrDefault(id) is DslNodeKinds.Delay or DslNodeKinds.WaitEvent + or DslNodeKinds.Approval); + + string key = string.Join("->", cycle.Order(StringComparer.Ordinal)); + + if (!yields && reported.Add(key)) + { + DslNode? owner = document.FindNode(cycle[0]); + diagnostics.Add(DslDiagnostic.Error( + DslCodes.TightCycle, owner?.Pointer ?? "/edges", + $"The cycle {string.Join(" -> ", cycle)} -> {cycle[0]} has nothing that yields.", + "Put a 'delay', 'wait-event' or 'approval' node on it, or break the cycle.")); + } + } + else if (colour == 0) + { + Visit(next); + } + } + + stack.RemoveAt(stack.Count - 1); + state[current] = 2; + } + } + + // ---- expressions -------------------------------------------------------------------------- + + private static void CheckExpressions( + DslDocument document, DslPolicy policy, List diagnostics) + { + foreach (DslNode node in document.Nodes) + { + foreach ((string pointer, string expression) in node.Expressions()) + { + // A gate predicate decides whether a node pauses. Resolved on the resume path too, + // so it carries the same determinism requirement as an edge condition. + bool requiresDeterminism = node.Gate is not null && pointer.EndsWith("/when", StringComparison.Ordinal); + Check(pointer, expression, requiresDeterminism, "gate predicate"); + } + + foreach ((string pointer, string template) in node.Templates()) + { + CheckTemplate(pointer, template); + } + } + + foreach (DslEdge edge in document.Edges) + { + if (edge.When is { Length: > 0 }) + { + Check($"{edge.Pointer}/when", edge.When, requiresDeterminism: true, "edge condition"); + } + + if (edge.Select is { Length: > 0 }) + { + Check($"{edge.Pointer}/select", edge.Select, requiresDeterminism: true, "edge selector"); + } + } + + foreach (DslTrigger trigger in document.Triggers) + { + if (trigger.CorrelationKey is { Length: > 0 } key) + { + Check($"{trigger.Pointer}/correlationKey", key, false, "correlation key"); + } + + if (trigger.ContextFrom is { Length: > 0 } from) + { + Check($"{trigger.Pointer}/contextFrom", from, false, "context projection"); + } + } + + if (document.Audit?.Key is { Length: > 0 } auditKey) + { + Check($"{document.Audit.Pointer}/key", auditKey, false, "audit key"); + } + + void Check(string pointer, string expression, bool requiresDeterminism, string role) + { + ExpressionFacts facts = AbExValidator.Check(expression, policy.MaxExpressionDepth); + + foreach (AbExIssue issue in facts.Issues) + { + string code = issue.Message.StartsWith("Unknown function", StringComparison.Ordinal) + ? DslCodes.UnknownFunction + : issue.Message.Contains("nests", StringComparison.Ordinal) + ? DslCodes.ExpressionTooDeep + : DslCodes.ExpressionParseError; + + diagnostics.Add(DslDiagnostic.Error( + code, pointer, $"{issue.Message} (at offset {issue.Offset})", issue.Suggestion)); + } + + // Routing must be reproducible. BuildAsync runs once per attempt, and a resumed instance + // has to retrace the branch its checkpoint recorded; a condition reading the clock could + // take a different one, which is silent, intermittent and close to undebuggable. + if (requiresDeterminism && facts.IsValid && !facts.IsDeterministic) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.NonDeterministicCondition, pointer, + $"A {role} must be deterministic, but this one reads '$run.now'.", + "A resumed run must retrace the routing its checkpoint recorded. " + + "Compute the value in a 'transform' node and compare against that instead.")); + } + } + + void CheckTemplate(string pointer, string template) + { + foreach ((string expression, int _) in TemplatePlaceholders(template)) + { + ExpressionFacts facts = AbExValidator.Check(expression, policy.MaxExpressionDepth); + + foreach (AbExIssue issue in facts.Issues) + { + diagnostics.Add(DslDiagnostic.Error( + issue.Message.StartsWith("Unknown function", StringComparison.Ordinal) + ? DslCodes.UnknownFunction + : DslCodes.ExpressionParseError, + pointer, + $"In placeholder '{{{{ {expression} }}}}': {issue.Message}", issue.Suggestion)); + } + } + } + } + + /// Extracts {{ ... }} placeholders, matching how the template engine scans. + internal static IEnumerable<(string Expression, int Offset)> TemplatePlaceholders(string template) + { + int index = 0; + + while (index < template.Length) + { + int open = template.IndexOf("{{", index, StringComparison.Ordinal); + if (open < 0) + { + yield break; + } + + int close = template.IndexOf("}}", open + 2, StringComparison.Ordinal); + if (close < 0) + { + yield break; + } + + yield return (template[(open + 2)..close].Trim(), open); + index = close + 2; + } + } + + // ---- gates and notifications ---------------------------------------------------------------- + + private static void CheckGates(DslDocument document, List diagnostics) + { + foreach (DslNode node in document.Nodes) + { + if (node.Gate is not { } gate) + { + continue; + } + + if (!DslNodeKinds.IsGateable(node.Kind)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.GateOnNonGateableKind, gate.Pointer, + $"A '{node.Kind}' node cannot carry an approval gate.", + "Gate the node that produces the value instead.")); + } + + if (string.Equals(gate.Mode, "conditional", StringComparison.Ordinal) && + string.IsNullOrWhiteSpace(gate.When)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.ConditionalGateWithoutPredicate, gate.Pointer, + "A conditional gate needs a 'when' predicate.", + "Without one it would never trip, which is the same as having no gate.")); + } + + if (string.Equals(gate.OnExpiryAction, "escalate", StringComparison.Ordinal) && + gate.EscalateTo.Count == 0) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.EscalationWithoutAssignees, $"{gate.Pointer}/onExpiry", + "Escalation on expiry needs someone to escalate to.")); + } + } + } + + private static void CheckNotifications( + DslDocument document, HashSet ids, List diagnostics) + { + if (document.Notifications is not { } notifications) + { + return; + } + + foreach (string node in notifications.ByNode.Keys.Where(k => !ids.Contains(k))) + { + diagnostics.Add(DslDiagnostic.Warning( + DslCodes.EdgeEndpointNotFound, + $"{notifications.Pointer}/byNode/{JsonPointer.Escape(node)}", + $"'{node}' is not a node, so this override does nothing.", Suggest(node, ids))); + } + } + + // ---- environment --------------------------------------------------------------------------- + + private static void CheckEnvironment( + DslDocument document, DslEnvironment? environment, + List diagnostics, List skipped) + { + if (environment is null) + { + // Reported rather than passed. A check that silently did not run is worse than one that + // openly did not, because only the second can be acted on. + skipped.Add(DslCodes.UnknownCustomNode); + skipped.Add(DslCodes.CustomNodeParameters); + skipped.Add(DslCodes.EgressHostsRequired); + skipped.Add(DslCodes.HashConflict); + return; + } + + foreach (DslCustomNode node in document.Nodes.OfType()) + { + if (!environment.CustomNodes.TryGetValue(node.NodeName, out JsonNode? parameterSchema)) + { + string? suggestion = Suggest( + node.NodeName, [.. environment.CustomNodes.Keys]); + + diagnostics.Add(DslDiagnostic.Error( + DslCodes.UnknownCustomNode, $"{node.Pointer}/node", + $"No custom node named '{node.NodeName}' is registered.", + suggestion ?? "Register it with AddDslNode(name, factory) before the document is loaded.")); + continue; + } + + if (parameterSchema is not null) + { + EvaluationResults results = JsonSchema.FromText(parameterSchema.ToJsonString()) + .Evaluate(node.With ?? new JsonObject(), new EvaluationOptions + { + OutputFormat = OutputFormat.List + }); + + if (!results.IsValid) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.CustomNodeParameters, $"{node.Pointer}/with", + $"The parameters do not match the schema published by '{node.NodeName}'.", + DescribeFirst(results))); + } + } + } + + if (environment.EnforceEgress) + { + foreach (DslHttpNode node in document.Nodes.OfType() + .Where(n => n.AllowedHosts.Count == 0)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.EgressHostsRequired, $"{node.Pointer}/allowedHosts", + $"Node '{node.Id}' calls out but declares no allowed hosts.", + "The host enforces an egress allow-list; a document cannot widen it, only name what it needs.")); + } + } + + string key = $"{document.Name}@{document.Version}"; + if (environment.PublishedHashes.TryGetValue(key, out string? published) && + !string.Equals(published, document.Hash, StringComparison.Ordinal)) + { + diagnostics.Add(DslDiagnostic.Error( + DslCodes.HashConflict, "/version", + $"'{key}' is already published with a different document.", + $"Published {published[..12]}…, this one is {document.Hash[..12]}…. " + + "A published version is immutable — bump the version instead.")); + } + } + + private static string? DescribeFirst(EvaluationResults results) + { + foreach (EvaluationResults detail in Flatten(results)) + { + if (detail.Errors is { Count: > 0 }) + { + return $"{detail.InstanceLocation}: {detail.Errors.First().Value}"; + } + } + + return null; + } + + private static IEnumerable Flatten(EvaluationResults results) + { + yield return results; + + foreach (EvaluationResults detail in results.Details) + { + foreach (EvaluationResults nested in Flatten(detail)) + { + yield return nested; + } + } + } +} diff --git a/tests/Abacus.Run.DslTests/DslDocumentReaderTests.cs b/tests/Abacus.Run.DslTests/DslDocumentReaderTests.cs new file mode 100644 index 0000000..2b1961d --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslDocumentReaderTests.cs @@ -0,0 +1,213 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class DslDocumentReaderTests +{ + private static DslDocument Read(string text) => DslParser.ParseOrThrow(text); + + [Fact] + public void Reads_document_level_fields() + { + DslDocument document = Read(DslFixtures.FullText); + + document.Dsl.Should().Be("abacus.workflow/1.0"); + document.Name.Should().Be("order-settlement"); + document.Version.Should().Be("1.2.0"); + document.Description.Should().Be("Prices an order, escalates large ones, settles."); + document.Start.Should().Be("validate"); + document.Output.Should().Equal("complete"); + document.ContextSchema.Should().NotBeNull(); + document.Hash.Should().MatchRegex("^[0-9a-f]{64}$"); + document.MajorVersion.Should().Be(1); + } + + [Theory] + [InlineData("abacus.workflow/1.0", 1)] + [InlineData("abacus.workflow/2.3", 2)] + [InlineData("abacus.workflow/10.0", 10)] + [InlineData("nonsense", -1)] + [InlineData("abacus.workflow/", -1)] + public void Major_version_is_extracted(string dsl, int expected) + => (Read(DslFixtures.MinimalText) with { Dsl = dsl }).MajorVersion.Should().Be(expected); + + [Fact] + public void Reads_node_pointers_in_declaration_order() + { + DslDocument document = Read(DslFixtures.FullText); + + document.Nodes.Select(n => n.Pointer).Should().Equal("/nodes/0", "/nodes/1", "/nodes/2"); + document.Nodes.Select(n => n.Id).Should().Equal("validate", "settle", "complete"); + } + + [Fact] + public void Reads_a_transform_node() + { + var node = (DslTransformNode)Read(DslFixtures.FullText).FindNode("validate")!; + + node.Set.Should().ContainKey("total").WhoseValue.Should().Be("$ctx.amount"); + node.Replace.Should().BeFalse(); + } + + [Fact] + public void Reads_an_http_node_with_its_gate() + { + var node = (DslHttpNode)Read(DslFixtures.FullText).FindNode("settle")!; + + node.Method.Should().Be("POST"); + node.Url.Should().Be("https://ledger.internal/v1/settlements"); + node.AllowedHosts.Should().Equal("ledger.internal"); + node.Body.Should().Contain("{{ $ctx.orderId }}"); + node.TimeoutSeconds.Should().Be(30); + node.SendIdempotencyKey.Should().BeTrue(); + + DslGate gate = node.Gate!; + gate.Mode.Should().Be("conditional"); + gate.When.Should().Be("$.total > 25000"); + gate.Reason.Should().Be("RegulatedSettlement"); + gate.AssignTo.Should().Equal("group:finance", "user:cfo"); + gate.RequireApprovers.Should().Be(2); + gate.ExpiresAfter.Should().Be(TimeSpan.FromHours(8)); + gate.OnExpiryAction.Should().Be("escalate"); + gate.EscalateTo.Should().Equal("group:exec"); + gate.AllowModification.Should().BeTrue(); + gate.RequireSegregationOfDuties.Should().BeTrue(); + gate.Locked.Should().BeTrue(); + gate.Pointer.Should().Be("/nodes/1/gate"); + } + + [Fact] + public void Gate_defaults_apply_when_unstated() + { + string text = DslFixtures.Broken(d => + DslFixtures.Node(d, 0)["gate"] = new JsonObject { ["mode"] = "requireApproval" }); + + DslGate gate = Read(text).Nodes[0].Gate!; + + gate.RequireApprovers.Should().Be(1); + gate.ExpiresAfter.Should().Be(TimeSpan.FromHours(24)); + gate.OnExpiryAction.Should().Be("deadStop"); + gate.Locked.Should().BeFalse(); + } + + [Theory] + [InlineData("PT30S", 30)] + [InlineData("PT5M", 300)] + [InlineData("PT8H", 28800)] + [InlineData("P3D", 259200)] + public void Reads_iso8601_durations(string iso, int expectedSeconds) + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "wait", ["kind"] = "delay", ["for"] = iso + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "wait" }); + d["output"] = new JsonArray("wait"); + }); + + var node = (DslDelayNode)Read(text).FindNode("wait")!; + node.For.Should().Be(TimeSpan.FromSeconds(expectedSeconds)); + } + + [Fact] + public void Reads_edges_including_barriers_and_fan_out() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "c", ["kind"] = "transform", ["set"] = new JsonObject { ["x"] = "1" } + }); + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "join", ["kind"] = "fan-in", ["into"] = "results" + }); + // Replaced rather than appended: the fixture's own a->b plus a fan-out a->[b,c] would be + // two unconditional edges between the same pair, which DSL0208 rightly refuses. + d["edges"] = new JsonArray( + new JsonObject { ["from"] = "a", ["to"] = new JsonArray("b", "c"), ["label"] = "split" }, + new JsonObject { ["from"] = new JsonArray("b", "c"), ["to"] = "join" }); + d["output"] = new JsonArray("join"); + }); + + DslDocument document = Read(text); + + DslEdge fanOut = document.Edges[0]; + fanOut.IsFanOut.Should().BeTrue(); + fanOut.IsBarrier.Should().BeFalse(); + fanOut.From.Should().Equal("a"); + fanOut.To.Should().Equal("b", "c"); + fanOut.Label.Should().Be("split"); + + DslEdge barrier = document.Edges[1]; + barrier.IsBarrier.Should().BeTrue(); + barrier.IsFanOut.Should().BeFalse(); + barrier.From.Should().Equal("b", "c"); + barrier.To.Should().Equal("join"); + barrier.Pointer.Should().Be("/edges/1"); + } + + [Fact] + public void Reads_triggers_notifications_failure_rules_and_audit() + { + DslDocument document = Read(DslFixtures.FullText); + + document.Triggers.Should().ContainSingle(); + document.Triggers[0].Topic.Should().Be("orders.placed"); + document.Triggers[0].CorrelationKey.Should().Be("$.orderId"); + + document.Notifications!.Level.Should().Be("standard"); + document.Notifications.Stream.Should().BeTrue(); + document.Notifications.Emits.Should().Equal("priced"); + + document.OnFailure.Should().ContainSingle(); + document.OnFailure[0].Exception.Should().Be("ApiCallFailureException"); + document.OnFailure[0].Status.Should().Be("5xx"); + document.OnFailure[0].Disposition.Should().Be("retry"); + + document.Audit!.Key.Should().Be("$ctx.orderId"); + document.Audit.Sections.Should().Equal("submission", "outcome"); + + document.Limits.MaxAttempts.Should().Be(5); + } + + [Fact] + public void Expressions_are_enumerated_with_pointers() + { + DslDocument document = Read(DslFixtures.FullText); + DslNode settle = document.FindNode("settle")!; + + settle.Expressions().Should().Contain(e => e.Pointer == "/nodes/1/gate/when"); + + DslNode validate = document.FindNode("validate")!; + validate.Expressions().Should().Contain(e => + e.Pointer == "/nodes/0/set/total" && e.Expression == "$ctx.amount"); + } + + [Fact] + public void Templates_are_enumerated_with_pointers() + { + DslNode settle = Read(DslFixtures.FullText).FindNode("settle")!; + + settle.Templates().Select(t => t.Pointer).Should().Contain("/nodes/1/url"); + settle.Templates().Select(t => t.Pointer).Should().Contain("/nodes/1/body"); + } + + [Fact] + public void Pointer_segments_are_escaped() + { + JsonPointer.Escape("a/b").Should().Be("a~1b"); + JsonPointer.Escape("a~b").Should().Be("a~0b"); + JsonPointer.Escape("plain").Should().Be("plain"); + } + + [Fact] + public void FindNode_returns_null_for_an_unknown_id() + => Read(DslFixtures.MinimalText).FindNode("nope").Should().BeNull(); +} diff --git a/tests/Abacus.Run.DslTests/DslFixtures.cs b/tests/Abacus.Run.DslTests/DslFixtures.cs new file mode 100644 index 0000000..16b28d9 --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslFixtures.cs @@ -0,0 +1,122 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; + +namespace Abacus.Run.DslTests; + +/// +/// Document fixtures. Tests start from a valid document and break exactly one thing, so a failure +/// names the check rather than a pile of unrelated diagnostics. +/// +internal static class DslFixtures +{ + /// A minimal valid document: two transform nodes and one edge. + internal const string MinimalText = """ + { + "dsl": "abacus.workflow/1.0", + "name": "minimal", + "version": "1.0.0", + "start": "a", + "output": ["b"], + "nodes": [ + { "id": "a", "kind": "transform", "set": { "seen": "true" } }, + { "id": "b", "kind": "transform", "set": { "done": "true" } } + ], + "edges": [ { "from": "a", "to": "b" } ] + } + """; + + /// The design's worked example: every optional block populated. + internal const string FullText = """ + { + "dsl": "abacus.workflow/1.0", + "name": "order-settlement", + "version": "1.2.0", + "description": "Prices an order, escalates large ones, settles.", + "context": { + "type": "object", + "required": ["orderId"], + "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } } + }, + "start": "validate", + "output": ["complete"], + "nodes": [ + { "id": "validate", "kind": "transform", + "set": { "total": "$ctx.amount" } }, + { "id": "settle", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/settlements", + "allowedHosts": ["ledger.internal"], + "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}", + "gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance", "user:cfo"], + "requireApprovers": 2, + "expiresAfter": "PT8H", + "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] }, + "allowModification": true, + "requireSegregationOfDuties": true, + "locked": true + } }, + { "id": "complete", "kind": "transform", "set": { "status": "'settled'" } } + ], + "edges": [ + { "from": "validate", "to": "settle", "when": "$.total > 0" }, + { "from": "settle", "to": "complete" } + ], + "triggers": [ { "topic": "orders.placed", "correlationKey": "$.orderId" } ], + "notifications": { "level": "standard", "stream": true, "emits": ["priced"] }, + "onFailure": [ + { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, "disposition": "retry" } + ], + "audit": { "key": "$ctx.orderId", "sections": ["submission", "outcome"] }, + "limits": { "maxAttempts": 5 } + } + """; + + internal static JsonObject Minimal() => (JsonObject)JsonNode.Parse(MinimalText)!; + + internal static JsonObject Full() => (JsonObject)JsonNode.Parse(FullText)!; + + /// Builds a document from the minimal fixture with one mutation applied. + internal static string Broken(Action mutate) + { + JsonObject document = Minimal(); + mutate(document); + return document.ToJsonString(); + } + + internal static string BrokenFull(Action mutate) + { + JsonObject document = Full(); + mutate(document); + return document.ToJsonString(); + } + + internal static JsonObject Node(JsonObject document, int index) => (JsonObject)document["nodes"]![index]!; + + internal static JsonObject Edge(JsonObject document, int index) => (JsonObject)document["edges"]![index]!; + + /// Asserts exactly one diagnostic of the code, and returns it for pointer assertions. + internal static DslDiagnostic ShouldReport( + this DslParseResult result, string code, DslSeverity severity = DslSeverity.Error) + { + DslDiagnostic[] matches = result.Validation.Diagnostics + .Where(d => d.Code == code).ToArray(); + + matches.Should().NotBeEmpty( + $"expected {code} but got:{Environment.NewLine}{result.Validation.Describe()}"); + + matches[0].Severity.Should().Be(severity); + matches[0].Pointer.Should().NotBeNull(); + return matches[0]; + } + + internal static void ShouldBeClean(this DslParseResult result) + { + result.IsValid.Should().BeTrue( + $"the document should be valid but reported:{Environment.NewLine}{result.Validation.Describe()}"); + } +} diff --git a/tests/Abacus.Run.DslTests/DslHashAndSchemaTests.cs b/tests/Abacus.Run.DslTests/DslHashAndSchemaTests.cs new file mode 100644 index 0000000..929ca39 --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslHashAndSchemaTests.cs @@ -0,0 +1,207 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class DslCanonicalHashTests +{ + private static string Hash(string json) => DslCanonicalHash.Compute(JsonNode.Parse(json)); + + [Fact] + public void Reformatting_does_not_change_the_hash() + { + string compact = """{"a":1,"b":"x"}"""; + string spaced = """ + { + "a" : 1 , + "b" : "x" + } + """; + + Hash(compact).Should().Be(Hash(spaced)); + } + + [Fact] + public void Property_order_does_not_change_the_hash() + => Hash("""{"a":1,"b":2,"c":3}""").Should().Be(Hash("""{"c":3,"a":1,"b":2}""")); + + [Fact] + public void Nested_property_order_does_not_change_the_hash() + => Hash("""{"outer":{"z":1,"a":2}}""").Should().Be(Hash("""{"outer":{"a":2,"z":1}}""")); + + [Fact] + public void Number_scale_does_not_change_the_hash() + => Hash("""{"a":1.50}""").Should().Be(Hash("""{"a":1.5}""")); + + [Fact] + public void Array_order_does_change_the_hash() + => Hash("""{"a":[1,2]}""").Should().NotBe(Hash("""{"a":[2,1]}""")); + + [Fact] + public void A_changed_value_changes_the_hash() + => Hash("""{"a":1}""").Should().NotBe(Hash("""{"a":2}""")); + + [Fact] + public void A_changed_key_changes_the_hash() + => Hash("""{"a":1}""").Should().NotBe(Hash("""{"b":1}""")); + + [Fact] + public void Hash_is_a_lowercase_sha256() + => Hash("""{"a":1}""").Should().MatchRegex("^[0-9a-f]{64}$"); + + [Fact] + public void Hash_is_stable_across_calls() + => Hash(DslFixtures.FullText).Should().Be(Hash(DslFixtures.FullText)); + + [Theory] + [InlineData("\"a\\\"b\"", "a\"b")] + [InlineData("\"a\\\\b\"", "a\\b")] + [InlineData("\"a\\nb\"", "a\nb")] + public void Strings_are_escaped_canonically(string json, string _) + => DslCanonicalHash.Canonicalize(JsonNode.Parse($"{{\"k\":{json}}}")) + .Should().StartWith("{\"k\":\""); + + [Fact] + public void Null_values_survive_canonicalisation() + => DslCanonicalHash.Canonicalize(JsonNode.Parse("""{"a":null}""")).Should().Be("""{"a":null}"""); + + [Fact] + public void Booleans_survive_canonicalisation() + => DslCanonicalHash.Canonicalize(JsonNode.Parse("""{"a":true,"b":false}""")) + .Should().Be("""{"a":true,"b":false}"""); +} + +public class DslSchemaResourceTests +{ + /// + /// One copy of the schema. If the embedded resource and the published file ever diverge, an + /// editor validates against one document and the host enforces another. + /// + [Fact] + public void The_embedded_schema_matches_the_published_file() + { + string? repositoryRoot = FindRepositoryRoot(); + repositoryRoot.Should().NotBeNull("the test needs the repository to compare against"); + + string path = Path.Combine(repositoryRoot!, "docs", "schema", "abacus-workflow-dsl-1.0.json"); + File.Exists(path).Should().BeTrue($"the published schema should be at {path}"); + + Normalise(File.ReadAllText(path)).Should().Be(Normalise(DslSchemaValidator.SchemaText)); + } + + [Fact] + public void The_embedded_schema_is_a_usable_schema() + { + DslSchemaValidator.SchemaText.Should().NotBeNullOrWhiteSpace(); + DslSchemaValidator.Validate(JsonNode.Parse(DslFixtures.MinimalText)).Should().BeEmpty(); + } + + [Fact] + public void The_schema_declares_draft_2020_12() + => DslSchemaValidator.SchemaText.Should().Contain("draft/2020-12/schema"); + + private static string Normalise(string text) + => text.ReplaceLineEndings("\n").TrimEnd(); + + private static string? FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory.FullName, "docs", "schema"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return null; + } +} + +/// +/// A validator has to survive the documents it exists to complain about. These are the shapes that +/// tempt a validator into throwing instead of diagnosing. +/// +public class DslValidatorRobustnessTests +{ + [Theory] + [InlineData("""{"dsl":"abacus.workflow/1.0","name":"x","version":"1.0.0","start":"a","nodes":[{"id":"a","kind":"transform","set":{"x":"1"}},{"id":"a","kind":"transform","set":{"y":"2"}}],"edges":[{"from":"a","to":"a"}]}""")] + [InlineData("""{"dsl":"abacus.workflow/1.0","name":"x","version":"1.0.0","start":"ghost","nodes":[{"id":"a","kind":"transform","set":{"x":"1"}}],"edges":[{"from":"ghost","to":"phantom"}]}""")] + [InlineData("""{"dsl":"abacus.workflow/1.0","name":"x","version":"1.0.0","start":"a","nodes":[{"id":"a","kind":"transform","set":{"x":"$.a = ="}}],"edges":[]}""")] + [InlineData("""{"dsl":"abacus.workflow/1.0","name":"x","version":"1.0.0","start":"a","nodes":[{"id":"a","kind":"transform","set":{"x":"1"}},{"id":"b","kind":"transform","set":{"x":"1"}},{"id":"c","kind":"transform","set":{"x":"1"}}],"edges":[{"from":"a","to":"b"},{"from":"b","to":"c"},{"from":"c","to":"a"}]}""")] + public void Pathological_documents_are_diagnosed_not_thrown(string text) + { + Action act = () => DslParser.Parse(text, new DslEnvironment { EnforceEgress = false }); + act.Should().NotThrow(); + } + + [Fact] + public void A_duplicate_id_inside_a_cycle_is_still_diagnosed() + { + string text = """ + { + "dsl": "abacus.workflow/1.0", "name": "x", "version": "1.0.0", "start": "a", + "nodes": [ + { "id": "a", "kind": "transform", "set": { "x": "1" } }, + { "id": "a", "kind": "transform", "set": { "y": "2" } }, + { "id": "b", "kind": "transform", "set": { "z": "3" } } + ], + "edges": [ { "from": "a", "to": "b" }, { "from": "b", "to": "a" } ] + } + """; + + DslParseResult result = DslParser.Parse(text, new DslEnvironment { EnforceEgress = false }); + + result.Validation.Has(DslCodes.DuplicateNodeId).Should().BeTrue(); + result.Validation.Has(DslCodes.TightCycle).Should().BeTrue(); + } + + [Fact] + public void An_empty_object_is_diagnosed() + => DslParser.Parse("{}").IsValid.Should().BeFalse(); + + [Fact] + public void ParseOrThrow_reports_every_diagnostic() + { + Action act = () => DslParser.ParseOrThrow( + DslFixtures.Broken(d => d["start"] = "nope"), source: "bad.json"); + + act.Should().Throw() + .Which.Message.Should().Contain("bad.json").And.Contain("DSL0202"); + } + + [Fact] + public void ParseOrThrow_returns_the_document_when_valid() + => DslParser.ParseOrThrow(DslFixtures.MinimalText).Name.Should().Be("minimal"); + + [Fact] + public void ParseFile_reports_a_missing_file_rather_than_throwing() + { + DslParseResult result = DslParser.ParseFile( + Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.json")); + + result.IsValid.Should().BeFalse(); + result.Validation.Has(DslCodes.MalformedJson).Should().BeTrue(); + } + + [Fact] + public void ParseFile_reads_a_real_document() + { + string path = Path.Combine(Path.GetTempPath(), $"dsl-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, DslFixtures.MinimalText); + + try + { + DslParser.ParseFile(path).IsValid.Should().BeTrue(); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/Abacus.Run.DslTests/DslValidationTests.cs b/tests/Abacus.Run.DslTests/DslValidationTests.cs new file mode 100644 index 0000000..07f04d2 --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslValidationTests.cs @@ -0,0 +1,692 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class DslValidationTests +{ + private static DslParseResult Parse(string text, DslEnvironment? environment = null) + => DslParser.Parse(text, environment); + + /// An environment with nothing registered, so the environment checks actually run. + private static DslEnvironment BareEnvironment(bool enforceEgress = false) + => new() { EnforceEgress = enforceEgress }; + + // ---- valid documents -------------------------------------------------------------------- + + [Fact] + public void Minimal_document_is_valid() => Parse(DslFixtures.MinimalText).ShouldBeClean(); + + [Fact] + public void The_designs_worked_example_is_valid() + => Parse(DslFixtures.FullText, BareEnvironment()).ShouldBeClean(); + + [Theory] + [InlineData("""{ "id": "n", "kind": "transform", "set": { "x": "1" } }""")] + [InlineData("""{ "id": "n", "kind": "http", "url": "https://x.internal/a", "allowedHosts": ["x.internal"] }""")] + [InlineData("""{ "id": "n", "kind": "llm", "model": "gpt", "prompt": "Summarise {{ $.text }}" }""")] + [InlineData("""{ "id": "n", "kind": "delay", "for": "PT5M" }""")] + [InlineData("""{ "id": "n", "kind": "approval" }""")] + [InlineData("""{ "id": "n", "kind": "publish", "topic": "orders.placed", "correlationKey": "$.id" }""")] + [InlineData("""{ "id": "n", "kind": "wait-event", "topic": "payment.settled", "timeout": "P3D" }""")] + [InlineData("""{ "id": "n", "kind": "fan-in", "into": "results" }""")] + public void Every_node_kind_parses(string nodeJson) + { + string text = DslFixtures.Broken(document => + { + var node = (JsonObject)JsonNode.Parse(nodeJson)!; + ((JsonArray)document["nodes"]!).Add(node); + ((JsonArray)document["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "n" }); + document["output"] = new JsonArray("n"); + }); + + Parse(text, BareEnvironment()).ShouldBeClean(); + } + + [Fact] + public void Custom_node_parses_when_registered() + { + string text = DslFixtures.Broken(document => + { + ((JsonArray)document["nodes"]!).Add(new JsonObject + { + ["id"] = "score", + ["kind"] = "custom", + ["node"] = "score-risk", + ["with"] = new JsonObject { ["threshold"] = 0.8 } + }); + ((JsonArray)document["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "score" }); + document["output"] = new JsonArray("score"); + }); + + var environment = new DslEnvironment + { + EnforceEgress = false, + CustomNodes = new Dictionary { ["score-risk"] = null } + }; + + Parse(text, environment).ShouldBeClean(); + } + + // ---- DSL01xx: identity ------------------------------------------------------------------ + + [Fact] + public void Malformed_json_is_reported() + { + DslParseResult result = Parse("{ not json"); + result.ShouldReport(DslCodes.MalformedJson).Pointer.Should().BeEmpty(); + } + + [Fact] + public void A_json_array_is_not_a_document() + => Parse("[]").ShouldReport(DslCodes.MalformedJson); + + [Fact] + public void Unsupported_dsl_version_is_reported() + { + // Schema-valid shape but a major version this interpreter does not read. + string text = DslFixtures.Broken(d => d["dsl"] = "abacus.workflow/1.0"); + var document = (JsonObject)JsonNode.Parse(text)!; + DslDocument model = DslDocumentReader.Read(document) with { Dsl = "abacus.workflow/2.0" }; + + DslValidationResult result = DslSemanticValidator.Validate(model); + result.Has(DslCodes.UnsupportedDslVersion).Should().BeTrue(); + } + + [Fact] + public void Wrong_dsl_constant_fails_the_schema() + => Parse(DslFixtures.Broken(d => d["dsl"] = "abacus.workflow/9.9")) + .ShouldReport(DslCodes.SchemaViolation).Pointer.Should().Be("/dsl"); + + [Fact] + public void Hash_conflict_is_reported() + { + DslParseResult first = Parse(DslFixtures.MinimalText, BareEnvironment()); + string differentText = DslFixtures.Broken(d => d["description"] = "changed"); + + var environment = new DslEnvironment + { + EnforceEgress = false, + PublishedHashes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["minimal@1.0.0"] = first.Document!.Hash + } + }; + + DslDiagnostic diagnostic = Parse(differentText, environment).ShouldReport(DslCodes.HashConflict); + diagnostic.Pointer.Should().Be("/version"); + diagnostic.Suggestion.Should().Contain("immutable"); + } + + [Fact] + public void The_same_document_does_not_conflict_with_itself() + { + DslParseResult first = Parse(DslFixtures.MinimalText, BareEnvironment()); + + var environment = new DslEnvironment + { + EnforceEgress = false, + PublishedHashes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["minimal@1.0.0"] = first.Document!.Hash + } + }; + + Parse(DslFixtures.MinimalText, environment).ShouldBeClean(); + } + + // ---- DSL02xx: references ---------------------------------------------------------------- + + [Fact] + public void Duplicate_node_id_is_reported() + { + string text = DslFixtures.Broken(d => DslFixtures.Node(d, 1)["id"] = "a"); + DslParseResult result = Parse(text); + + result.ShouldReport(DslCodes.DuplicateNodeId).Pointer.Should().Be("/nodes/1/id"); + } + + [Fact] + public void Unknown_start_is_reported() + { + DslDiagnostic diagnostic = Parse(DslFixtures.Broken(d => d["start"] = "nope")) + .ShouldReport(DslCodes.StartNotFound); + + diagnostic.Pointer.Should().Be("/start"); + } + + [Fact] + public void Unknown_output_is_reported() + => Parse(DslFixtures.Broken(d => d["output"] = new JsonArray("nope"))) + .ShouldReport(DslCodes.OutputNotFound).Pointer.Should().Be("/output/0"); + + [Fact] + public void Unknown_edge_target_is_reported_with_a_suggestion() + { + DslDiagnostic diagnostic = Parse(DslFixtures.Broken(d => DslFixtures.Edge(d, 0)["to"] = "bb")) + .ShouldReport(DslCodes.EdgeEndpointNotFound); + + diagnostic.Pointer.Should().Be("/edges/0/to"); + diagnostic.Suggestion.Should().Contain("'b'"); + } + + [Fact] + public void Unknown_edge_source_is_reported() + => Parse(DslFixtures.Broken(d => DslFixtures.Edge(d, 0)["from"] = "zzz")) + .ShouldReport(DslCodes.EdgeEndpointNotFound).Pointer.Should().Be("/edges/0/from"); + + [Fact] + public void Fan_out_endpoints_report_the_element_pointer() + { + string text = DslFixtures.Broken(d => + DslFixtures.Edge(d, 0)["to"] = new JsonArray("b", "missing")); + + Parse(text).ShouldReport(DslCodes.EdgeEndpointNotFound).Pointer.Should().Be("/edges/0/to/1"); + } + + [Fact] + public void Duplicate_unconditional_edge_is_reported() + { + string text = DslFixtures.Broken(d => + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "a", ["to"] = "b" })); + + Parse(text).ShouldReport(DslCodes.DuplicateEdge).Pointer.Should().Be("/edges/1"); + } + + [Fact] + public void Duplicate_edge_is_allowed_when_marked_idempotent() + { + string text = DslFixtures.Broken(d => + ((JsonArray)d["edges"]!).Add(new JsonObject + { + ["from"] = "a", ["to"] = "b", ["idempotent"] = true + })); + + Parse(text).ShouldBeClean(); + } + + /// Two conditional edges between the same pair is how a branch with a fallback is written. + [Fact] + public void Two_conditional_edges_between_the_same_pair_are_allowed() + { + string text = DslFixtures.Broken(d => + { + DslFixtures.Edge(d, 0)["when"] = "$.x > 1"; + ((JsonArray)d["edges"]!).Add(new JsonObject + { + ["from"] = "a", ["to"] = "b", ["when"] = "$.x <= 1" + }); + }); + + Parse(text).ShouldBeClean(); + } + + // ---- DSL03xx: graph shape --------------------------------------------------------------- + + [Fact] + public void Unreachable_node_is_a_warning() + { + string text = DslFixtures.Broken(d => + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "orphan", + ["kind"] = "transform", + ["set"] = new JsonObject { ["x"] = "1" } + })); + + DslDiagnostic diagnostic = Parse(text) + .ShouldReport(DslCodes.UnreachableNode, DslSeverity.Warning); + + diagnostic.Pointer.Should().Be("/nodes/2"); + Parse(text).IsValid.Should().BeTrue("an unreachable node is a warning, not an error"); + } + + [Fact] + public void Dead_end_node_is_a_warning() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "c", ["kind"] = "transform", ["set"] = new JsonObject { ["x"] = "1" } + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "c" }); + }); + + Parse(text).ShouldReport(DslCodes.DeadEndNode, DslSeverity.Warning); + } + + [Fact] + public void A_cycle_with_nothing_that_yields_is_refused() + { + string text = DslFixtures.Broken(d => + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "a" })); + + DslDiagnostic diagnostic = Parse(text).ShouldReport(DslCodes.TightCycle); + diagnostic.Message.Should().Contain("nothing that yields"); + diagnostic.Suggestion.Should().Contain("delay"); + } + + [Theory] + [InlineData("delay", """{ "id": "wait", "kind": "delay", "for": "PT1M" }""")] + [InlineData("wait-event", """{ "id": "wait", "kind": "wait-event", "topic": "x.y" }""")] + [InlineData("approval", """{ "id": "wait", "kind": "approval" }""")] + public void A_cycle_is_allowed_when_something_on_it_yields(string _, string nodeJson) + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add((JsonObject)JsonNode.Parse(nodeJson)!); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "wait" }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "wait", ["to"] = "a" }); + }); + + Parse(text).Validation.Has(DslCodes.TightCycle).Should().BeFalse(); + } + + [Fact] + public void Unreachable_barrier_source_is_refused() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "orphan", ["kind"] = "transform", ["set"] = new JsonObject { ["x"] = "1" } + }); + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "join", ["kind"] = "fan-in", ["into"] = "results" + }); + ((JsonArray)d["edges"]!).Add(new JsonObject + { + ["from"] = new JsonArray("b", "orphan"), ["to"] = "join" + }); + d["output"] = new JsonArray("join"); + }); + + DslDiagnostic diagnostic = Parse(text).ShouldReport(DslCodes.BarrierSourceUnreachable); + diagnostic.Message.Should().Contain("never release"); + } + + // ---- DSL04xx: expressions --------------------------------------------------------------- + + [Fact] + public void Unparseable_expression_is_reported_at_its_pointer() + { + DslDiagnostic diagnostic = Parse(DslFixtures.Broken(d => DslFixtures.Edge(d, 0)["when"] = "$.a = 1")) + .ShouldReport(DslCodes.ExpressionParseError); + + diagnostic.Pointer.Should().Be("/edges/0/when"); + diagnostic.Message.Should().Contain("'=='"); + } + + [Fact] + public void Unparseable_transform_expression_points_at_the_key() + { + string text = DslFixtures.Broken(d => + DslFixtures.Node(d, 0)["set"] = new JsonObject { ["total"] = "$.a +" }); + + Parse(text).ShouldReport(DslCodes.ExpressionParseError).Pointer.Should().Be("/nodes/0/set/total"); + } + + [Fact] + public void Unknown_function_is_reported_with_a_suggestion() + { + DslDiagnostic diagnostic = Parse(DslFixtures.Broken(d => DslFixtures.Edge(d, 0)["when"] = "lenn($.a) > 0")) + .ShouldReport(DslCodes.UnknownFunction); + + diagnostic.Pointer.Should().Be("/edges/0/when"); + diagnostic.Suggestion.Should().Contain("len"); + } + + /// + /// The rule that keeps a resumed run on the branch its checkpoint recorded. + /// + [Fact] + public void Non_deterministic_edge_condition_is_refused() + { + DslDiagnostic diagnostic = Parse(DslFixtures.Broken(d => DslFixtures.Edge(d, 0)["when"] = "$run.now > '2020'")) + .ShouldReport(DslCodes.NonDeterministicCondition); + + diagnostic.Pointer.Should().Be("/edges/0/when"); + diagnostic.Suggestion.Should().Contain("checkpoint"); + } + + [Fact] + public void Non_deterministic_gate_predicate_is_refused() + { + string text = DslFixtures.Broken(d => DslFixtures.Node(d, 1)["gate"] = new JsonObject + { + ["mode"] = "conditional", + ["when"] = "$run.now != null" + }); + + Parse(text).ShouldReport(DslCodes.NonDeterministicCondition) + .Pointer.Should().Be("/nodes/1/gate/when"); + } + + /// Templates are rendered, not routed on, so the clock is fair game there. + [Fact] + public void Non_deterministic_template_is_allowed() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "call", + ["kind"] = "http", + ["url"] = "https://x.internal/at/{{ $run.now }}", + ["allowedHosts"] = new JsonArray("x.internal") + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "call" }); + d["output"] = new JsonArray("call"); + }); + + Parse(text, BareEnvironment()).ShouldBeClean(); + } + + [Fact] + public void Broken_template_placeholder_is_reported() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "call", + ["kind"] = "http", + ["url"] = "https://x.internal/{{ nope($.a) }}", + ["allowedHosts"] = new JsonArray("x.internal") + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "call" }); + d["output"] = new JsonArray("call"); + }); + + DslDiagnostic diagnostic = Parse(text, BareEnvironment()).ShouldReport(DslCodes.UnknownFunction); + diagnostic.Pointer.Should().Be("/nodes/2/url"); + diagnostic.Message.Should().Contain("placeholder"); + } + + [Fact] + public void Expression_deeper_than_the_policy_is_reported() + { + string deep = string.Join(" + ", Enumerable.Range(1, 12).Select(i => i.ToString())); + string text = DslFixtures.Broken(d => + DslFixtures.Node(d, 0)["set"] = new JsonObject { ["x"] = deep }); + + var environment = new DslEnvironment + { + EnforceEgress = false, + Policy = DslPolicy.Default with { MaxExpressionDepth = 4 } + }; + + Parse(text, environment).ShouldReport(DslCodes.ExpressionTooDeep); + } + + // ---- DSL05xx: gates --------------------------------------------------------------------- + + [Fact] + public void Gate_on_a_fan_in_node_is_refused_by_the_schema() + { + string text = DslFixtures.Broken(d => + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "join", + ["kind"] = "fan-in", + ["gate"] = new JsonObject { ["mode"] = "requireApproval" } + })); + + Parse(text).IsValid.Should().BeFalse(); + } + + /// + /// The semantic check exists independently of the schema's, because the schema can only forbid + /// the kinds it knows about today. + /// + [Fact] + public void Gate_on_a_non_gateable_kind_is_refused_semantically() + { + var model = DslDocumentReader.Read(DslFixtures.Minimal()) with + { + Nodes = + [ + new DslFanInNode + { + Id = "join", + Kind = DslNodeKinds.FanIn, + Pointer = "/nodes/0", + Gate = new DslGate { Mode = "requireApproval", Pointer = "/nodes/0/gate" } + } + ], + Start = "join", + Output = ["join"], + Edges = [] + }; + + DslValidationResult result = DslSemanticValidator.Validate(model); + result.Has(DslCodes.GateOnNonGateableKind).Should().BeTrue(); + } + + [Fact] + public void Conditional_gate_without_a_predicate_is_refused() + { + // The schema catches this too; the semantic check is asserted directly so both layers hold. + var model = DslDocumentReader.Read(DslFixtures.Minimal()); + DslNode gated = model.Nodes[0] with + { + Gate = new DslGate { Mode = "conditional", Pointer = "/nodes/0/gate" } + }; + + DslValidationResult result = DslSemanticValidator.Validate( + model with { Nodes = [gated, model.Nodes[1]] }); + + result.Has(DslCodes.ConditionalGateWithoutPredicate).Should().BeTrue(); + } + + [Fact] + public void Escalation_without_assignees_is_refused() + { + var model = DslDocumentReader.Read(DslFixtures.Minimal()); + DslNode gated = model.Nodes[0] with + { + Gate = new DslGate + { + Mode = "requireApproval", + OnExpiryAction = "escalate", + Pointer = "/nodes/0/gate" + } + }; + + DslValidationResult result = DslSemanticValidator.Validate( + model with { Nodes = [gated, model.Nodes[1]] }); + + result.Has(DslCodes.EscalationWithoutAssignees).Should().BeTrue(); + } + + // ---- DSL06xx: environment --------------------------------------------------------------- + + [Fact] + public void Unregistered_custom_node_is_refused() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "score", ["kind"] = "custom", ["node"] = "score-risk" + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "score" }); + d["output"] = new JsonArray("score"); + }); + + DslDiagnostic diagnostic = Parse(text, BareEnvironment()).ShouldReport(DslCodes.UnknownCustomNode); + diagnostic.Pointer.Should().Be("/nodes/2/node"); + diagnostic.Suggestion.Should().Contain("AddDslNode"); + } + + [Fact] + public void Custom_node_parameters_are_checked_against_the_factory_schema() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "score", + ["kind"] = "custom", + ["node"] = "score-risk", + ["with"] = new JsonObject { ["threshold"] = "not a number" } + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "score" }); + d["output"] = new JsonArray("score"); + }); + + var environment = new DslEnvironment + { + EnforceEgress = false, + CustomNodes = new Dictionary + { + ["score-risk"] = JsonNode.Parse( + """{ "type": "object", "properties": { "threshold": { "type": "number" } } }""") + } + }; + + Parse(text, environment).ShouldReport(DslCodes.CustomNodeParameters) + .Pointer.Should().Be("/nodes/2/with"); + } + + [Fact] + public void Http_node_without_allowed_hosts_is_refused_when_egress_is_enforced() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "call", ["kind"] = "http", ["url"] = "https://x.internal/a" + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "call" }); + d["output"] = new JsonArray("call"); + }); + + DslDiagnostic diagnostic = Parse(text, BareEnvironment(enforceEgress: true)) + .ShouldReport(DslCodes.EgressHostsRequired); + + diagnostic.Pointer.Should().Be("/nodes/2/allowedHosts"); + } + + /// + /// Offline linting has no host. The checks that need one must report as skipped rather than + /// pass, because a check that silently did not run is worse than one that openly did not. + /// + [Fact] + public void Environment_checks_are_skipped_not_passed_when_there_is_no_environment() + { + string text = DslFixtures.Broken(d => + { + ((JsonArray)d["nodes"]!).Add(new JsonObject + { + ["id"] = "score", ["kind"] = "custom", ["node"] = "never-registered" + }); + ((JsonArray)d["edges"]!).Add(new JsonObject { ["from"] = "b", ["to"] = "score" }); + d["output"] = new JsonArray("score"); + }); + + DslParseResult result = Parse(text); + + result.Validation.Has(DslCodes.UnknownCustomNode).Should().BeFalse(); + result.Validation.SkippedChecks.Should().Contain(DslCodes.UnknownCustomNode); + result.Validation.SkippedChecks.Should().Contain(DslCodes.EgressHostsRequired); + result.Validation.SkippedChecks.Should().Contain(DslCodes.HashConflict); + } + + [Fact] + public void Nothing_is_skipped_when_an_environment_is_supplied() + => Parse(DslFixtures.MinimalText, BareEnvironment()).Validation.SkippedChecks.Should().BeEmpty(); + + // ---- DSL07xx: limits -------------------------------------------------------------------- + + [Fact] + public void A_document_over_the_byte_limit_is_refused() + { + var environment = new DslEnvironment { Policy = DslPolicy.Default with { MaxDocumentBytes = 64 } }; + Parse(DslFixtures.MinimalText, environment).ShouldReport(DslCodes.LimitExceeded); + } + + [Fact] + public void Too_many_nodes_is_refused() + { + var environment = new DslEnvironment + { + EnforceEgress = false, + Policy = DslPolicy.Default with { MaxNodes = 1 } + }; + + Parse(DslFixtures.MinimalText, environment).ShouldReport(DslCodes.LimitExceeded) + .Pointer.Should().Be("/nodes"); + } + + [Fact] + public void Too_many_edges_is_refused() + { + var environment = new DslEnvironment + { + EnforceEgress = false, + Policy = DslPolicy.Default with { MaxEdges = 0 } + }; + + Parse(DslFixtures.MinimalText, environment).ShouldReport(DslCodes.LimitExceeded) + .Pointer.Should().Be("/edges"); + } + + // ---- schema-level structural errors ------------------------------------------------------- + + [Theory] + [InlineData("name")] + [InlineData("version")] + [InlineData("start")] + [InlineData("nodes")] + public void Missing_required_property_is_a_schema_violation(string property) + => Parse(DslFixtures.Broken(d => d.Remove(property))).ShouldReport(DslCodes.SchemaViolation); + + [Fact] + public void Unknown_top_level_property_is_refused() + => Parse(DslFixtures.Broken(d => d["script"] = "rm -rf /")) + .ShouldReport(DslCodes.SchemaViolation); + + [Fact] + public void Uppercase_node_id_is_refused() + => Parse(DslFixtures.Broken(d => DslFixtures.Node(d, 0)["id"] = "Validate")) + .ShouldReport(DslCodes.SchemaViolation); + + [Fact] + public void Non_semver_version_is_refused() + => Parse(DslFixtures.Broken(d => d["version"] = "1.0")).ShouldReport(DslCodes.SchemaViolation); + + [Fact] + public void Unknown_kind_is_refused() + => Parse(DslFixtures.Broken(d => DslFixtures.Node(d, 0)["kind"] = "script")) + .ShouldReport(DslCodes.SchemaViolation); + + [Fact] + public void An_unlisted_exception_name_is_refused() + { + string text = DslFixtures.Broken(d => d["onFailure"] = new JsonArray( + new JsonObject + { + ["match"] = new JsonObject { ["exception"] = "MyOwnException" }, + ["disposition"] = "retry" + })); + + Parse(text).ShouldReport(DslCodes.SchemaViolation); + } + + [Fact] + public void Structural_errors_stop_before_the_semantic_pass() + { + // Removing 'nodes' would make every reference check fire; the caller should see the one real + // problem instead of a cascade from a half-understood document. + DslParseResult result = Parse(DslFixtures.Broken(d => d.Remove("nodes"))); + + result.Document.Should().BeNull(); + result.Validation.Diagnostics.Should().OnlyContain(d => d.Code == DslCodes.SchemaViolation); + } +} From ed9eca3f9a80d60331f2e510cfaa3340ceb05046 Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 22:31:14 +0100 Subject: [PATCH 5/8] feat(dsl): phases 3 and 4 - interpreter and host integration A DSL document now registers and runs as an ordinary workflow definition. Every kind maps onto an executor the host already ships. Nothing here reimplements HTTP, prompting, egress control, idempotency keys, durable waits or cost accounting: DslHostedExecutor calls ExecuteTerminalAsync on the real executor and projects the result back into the envelope, skipping the inner gate and pipeline because the outer node has already run both. A front end that forked the execution path would stop being one. Custom nodes go through the same wrapper, so a factory author writes ExecuteCoreAsync and gets the bound expression roots, the declared notification and the result projection for free. The graph gained an entry and an exit node, neither of them planned. The runner sends the deserialized context as the first message, typed JsonElement, and the engine routes by type - so without a node typed to receive it, the first DSL node never runs and the workflow completes having done nothing at all. The exit node is the mirror: YieldOutputAsync is checked against the executor's declared output type, so a DSL node cannot yield anything but an envelope, and the caller would otherwise be handed the start context back as though it were a result. Registration is deferred until the container is built, so AddDslWorkflow and AddDslNode can be written in either order - a document is always validated against the complete node catalog. An invalid document fails startup with every diagnostic, not the first: three broken documents should take one startup to fix. Two defects surfaced through their own tests. AbExValue.FromNode probed CLR types in turn, and a JsonValue created from an int will not hand back a decimal, so an HTTP status of 200 fell through to the string branch and never equalled 200. And the semantic validator crashed on duplicate node ids, which is one of the things it exists to report. Two pre-existing issues are recorded in the plan rather than fixed here: FanInExecutor cannot work with AddFanInBarrierEdge, because the engine type-checks a barrier target against the individual message and not the list; and no ITimerService is registered anywhere, so DelayExecutor cannot run on a stock host. Suites: 723 unit (unchanged), 358 DSL unit, 201 integration (+51), 7 chaos. --- .../07-workflow-dsl-implementation-plan.md | 84 ++- src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj | 7 + src/Abacus.Run.Dsl/Expressions/AbExValue.cs | 51 +- src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs | 102 ++++ .../Hosting/DslHostBuilderExtensions.cs | 166 ++++++ src/Abacus.Run.Dsl/Hosting/DslRegistry.cs | 157 +++++ .../Interpretation/DslBuiltInNodes.cs | 451 +++++++++++++++ .../Interpretation/DslExecutor.cs | 171 ++++++ .../Interpretation/DslExpressions.cs | 95 +++ .../Interpretation/DslWorkflowDefinition.cs | 491 ++++++++++++++++ .../Interpretation/IDslNodeFactory.cs | 111 ++++ .../Validation/DslSemanticValidator.cs | 13 +- .../Abacus.Run.Service.csproj | 1 + src/Abacus.Run.Service/Program.cs | 9 +- .../Properties/launchSettings.json | 12 + src/Abacus.Run/Core/WorkflowRegistry.cs | 32 +- .../Abacus.Run.DslTests/AbExEvaluatorTests.cs | 61 ++ .../DslInterpreterTests.cs | 446 ++++++++++++++ .../Abacus.Run.IntegrationTests.csproj | 1 + .../DslDocuments.cs | 372 ++++++++++++ .../DslHostFixture.cs | 287 +++++++++ .../DslHostingTests.cs | 368 ++++++++++++ .../DslWorkflowTests.cs | 545 ++++++++++++++++++ 23 files changed, 4018 insertions(+), 15 deletions(-) create mode 100644 src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs create mode 100644 src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs create mode 100644 src/Abacus.Run.Dsl/Hosting/DslRegistry.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/DslExpressions.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs create mode 100644 src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs create mode 100644 src/Abacus.Run.Service/Properties/launchSettings.json create mode 100644 tests/Abacus.Run.DslTests/DslInterpreterTests.cs create mode 100644 tests/Abacus.Run.IntegrationTests/DslDocuments.cs create mode 100644 tests/Abacus.Run.IntegrationTests/DslHostFixture.cs create mode 100644 tests/Abacus.Run.IntegrationTests/DslHostingTests.cs create mode 100644 tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md index 84c478e..01142aa 100644 --- a/docs/implementation/07-workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -7,13 +7,89 @@ workflow behaves; the DSL is a second front end onto the runtime that already ex | # | Phase | Delivers | Depends on | Status | | - | ----- | -------- | ---------- | ------ | -| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ✅ Done — 207 tests | -| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ✅ Done — 317 tests | -| 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ⬜ Not started | -| 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ⬜ Not started | +| 1 | Envelope and expression core | `DslMessage`, AbEx parser and evaluator | — | ✅ Done | +| 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ✅ Done | +| 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ✅ Done | +| 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ✅ Done | | 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ⬜ Not started | | 6 | Deferred | Runtime publication API, sub-workflows, iteration | 5 | ⬜ Out of scope | +## Status + +Phases 1–4 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, **201 integration** +(+51), **7 chaos**. + +| Delivered | Where | +| --------- | ----- | +| `DslMessage` envelope, `$run` metadata, self-resolving templates | [Interpretation/DslMessage.cs](../../src/Abacus.Run.Dsl/Interpretation/DslMessage.cs) | +| AbEx lexer, parser, AST, evaluator, static analysis, closed function set | [Expressions/](../../src/Abacus.Run.Dsl/Expressions/) | +| Typed document model with a JSON Pointer on every element | [Model/](../../src/Abacus.Run.Dsl/Model/) | +| Schema validation, 22-code semantic validator, canonical hash | [Validation/](../../src/Abacus.Run.Dsl/Validation/) | +| Entry/exit nodes, per-kind factories, hosted-executor adapter | [Interpretation/DslBuiltInNodes.cs](../../src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs) | +| Graph construction, gates, failure rules, notifications, triggers | [Interpretation/DslWorkflowDefinition.cs](../../src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs) | +| Deferred registration, directory loading, custom node catalog | [Hosting/](../../src/Abacus.Run.Dsl/Hosting/) | +| `IContextValidatingWorkflow`, consulted after the type bind | [Core/WorkflowRegistry.cs](../../src/Abacus.Run/Core/WorkflowRegistry.cs) | +| `ITemplateBindingSource` | [Executors/TemplateEngine.cs](../../src/Abacus.Run/Executors/TemplateEngine.cs) | +| `/dsl/schema`, `/dsl/nodes`, `/dsl/functions`, `/dsl/documents`, `/dsl/validate` | [Hosting/DslEndpoints.cs](../../src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs) | + +### Deviations from the plan as written + +**The core change is two interfaces, not one.** `IContextValidatingWorkflow` was planned. +`ITemplateBindingSource` was not: `TemplateBindings` resolves dotted paths by reflection over a +single root object, which cannot address an envelope carrying both a context and a payload. It is +additive and opt-in — a type that does not implement it resolves exactly as before — and it is what +lets `{{ $ctx.orderId }}` work inside the existing `ApiCallExecutor` and `LlmExecutor` rather than +forking either. + +**Two grammar changes.** Unary `!` and `-` bind tightest, rather than sitting between `&&` and +comparison as first written — `!has($.x) && …` is the common shape and standard precedence is what +an author expects. And bare-identifier path roots are gone: every path starts `$`, `$ctx` or `$run`, +which removes a real ambiguity between a path and a function name. + +**The graph has an entry and an exit node.** Neither was planned. The runner sends the deserialized +context as the first message, typed `JsonElement`, and the engine routes by type — so without a node +typed to receive it the first DSL node never runs and the workflow completes having done nothing. +The exit node exists for the mirror reason: `YieldOutputAsync` is checked against the executor's +declared output type, so a DSL node cannot yield anything but an envelope, and the caller would +otherwise get the start context back as though it were a result. Both use ids (`$entry`, `$exit`) +that a declared node id cannot collide with. + +**Fan-in aggregates across invocations.** The plan assumed `AddFanInBarrierEdge` delivers a list. +It does not: `FanInEdgeRunner` type-checks the target against the *individual* message and delivers +the released messages separately. The DSL node therefore holds arrivals and emits once the last one +lands, with the expected count read from the document. See the note below — the framework's own +`FanInExecutor` has the same problem and does not work with a barrier edge. + +**Trigger `correlationKey` is a literal, not an expression.** A trigger subscription is registered +before any message exists, so there is nothing for a path to read. The validator now warns when one +is written to look like an expression rather than silently evaluating or silently dropping it. +`contextFrom` *is* an expression, and is wired to `DomainEventTrigger.ContextSelector`. + +### Defects found by these tests + +**`AbExValue.FromNode` misread numbers.** It probed CLR types in turn, and a `JsonValue` created +from an `int` will not hand back a `decimal` — so `JsonValue.Create(200)` fell through to the string +branch and an HTTP status of 200 compared as `"200"`, never equalling `200`. Now classified by +`GetValueKind()` first. Regression-tested across int, long, double, decimal and float backing, and +across a serialization round trip. + +**The semantic validator crashed on duplicate node ids** — one of the things it exists to report — +because it built its kind lookup with `ToDictionary`. Now built tolerantly, with robustness tests +over pathological documents. + +### Pre-existing issues found, not fixed here + +**`FanInExecutor` cannot work with `AddFanInBarrierEdge`.** It is declared +`HostExecutor, TOut>`, but `FanInEdgeRunner.ChaseEdgeAsync` filters released messages by +`CanHandle(target, individualMessageType)` — a target declaring `List` matches nothing and the +delivery is dropped as a type mismatch. Nothing in the repository exercises it, and the wiki's +"the barrier delivers a list" is wrong. Out of scope for the DSL, which works around it, but it is a +real defect in the compiled surface. + +**No `ITimerService` is registered anywhere in the host.** `DelayExecutor` requires one, so a `delay` +node — and a compiled workflow using `DelayExecutor` — cannot run on a stock host. The DSL test +fixture registers an in-memory implementation; a deployable host has nothing. + Phases 1–2 are independently testable with no host involved and carry most of the risk. Phase 3 is mechanical once they land. Phase 4 is small — deliberately, because the design keeps the core change to a single opt-in interface. diff --git a/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj index 47c771a..0f1ba6c 100644 --- a/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj +++ b/src/Abacus.Run.Dsl/Abacus.Run.Dsl.csproj @@ -34,6 +34,13 @@ LogicalName="Abacus.Run.Dsl.Schema.abacus-workflow-dsl-1.0.json" /> + + + + + + diff --git a/src/Abacus.Run.Dsl/Expressions/AbExValue.cs b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs index 3370757..4fb7188 100644 --- a/src/Abacus.Run.Dsl/Expressions/AbExValue.cs +++ b/src/Abacus.Run.Dsl/Expressions/AbExValue.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json; using System.Text.Json.Nodes; namespace Abacus.Run.Dsl.Expressions; @@ -77,19 +78,57 @@ public static AbExValue FromNode(JsonNode? node) return new AbExValue(AbExValueKind.Object, node: obj); case JsonValue value: - if (value.TryGetValue(out bool b)) return Bool(b); - if (value.TryGetValue(out decimal d)) return Number(d); - if (value.TryGetValue(out string? s) && s is not null) return String(s); + // Classified by JSON kind first, not by trying CLR types in turn. A JsonValue holds + // whatever the writer put in it — JsonValue.Create(200) is backed by int, and asking + // it for a decimal simply fails — so type-probing quietly turned numbers into + // strings and made '$.status == 200' false against a genuine 200. + switch (value.GetValueKind()) + { + case JsonValueKind.True: + return True; - // A JsonValue wrapping something exotic still has a JSON text form; fall back to it - // rather than reporting absence for a value that demonstrably exists. - return String(value.ToJsonString().Trim('"')); + case JsonValueKind.False: + return False; + + case JsonValueKind.Number: + return Number(ReadNumber(value)); + + case JsonValueKind.String: + return value.TryGetValue(out string? text) && text is not null + ? String(text) + : String(value.ToJsonString().Trim('"')); + + case JsonValueKind.Null: + return Null; + + default: + return String(value.ToJsonString().Trim('"')); + } default: return String(node.ToJsonString()); } } + /// + /// Reads a number whatever CLR type backs it. Decimal throughout, so money keeps its scale; a + /// double that will not fit is taken as a double and converted, which loses precision but never + /// loses the value. + /// + private static decimal ReadNumber(JsonValue value) + { + if (value.TryGetValue(out decimal d)) return d; + if (value.TryGetValue(out long l)) return l; + if (value.TryGetValue(out int i)) return i; + if (value.TryGetValue(out double dbl)) return (decimal)dbl; + if (value.TryGetValue(out float f)) return (decimal)f; + + return decimal.TryParse( + value.ToJsonString(), NumberStyles.Number, CultureInfo.InvariantCulture, out decimal parsed) + ? parsed + : 0m; + } + /// Length as len() defines it: characters, elements, or properties. public int Length => Kind switch { diff --git a/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs b/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs new file mode 100644 index 0000000..b3e775d --- /dev/null +++ b/src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Dsl.Validation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Abacus.Run.Dsl.Hosting; + +/// The DSL's own control-plane routes: validate, schema, node catalog. +public static class DslEndpoints +{ + /// + /// Maps the DSL routes. Mounted beside MapWorkflowApi, under the same prefix and the same + /// authorization — the validate route reflects the host's registered node names back to the + /// caller, which is information about the host and not something to serve anonymously. + /// + public static IEndpointRouteBuilder MapDslApi(this IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapGet("/dsl/schema", () => Results.Text( + DslSchemaValidator.SchemaText, "application/schema+json")); + + app.MapGet("/dsl/nodes", (DslRegistry registry) => Results.Ok(new + { + builtIn = Model.DslNodeKinds.All, + custom = registry.Catalog.Describe().Select(entry => new + { + name = entry.Key, + parameterSchema = entry.Value + }) + })); + + app.MapGet("/dsl/functions", () => Results.Ok( + Expressions.AbExFunctions.Names + .Select(name => + { + Expressions.AbExFunctions.TryGet(name, out Expressions.AbExFunction function); + return new { name, arity = function.DescribeArity(), summary = function.Summary }; + }))); + + app.MapGet("/dsl/documents", (DslRegistry registry) => Results.Ok( + registry.Definitions.Select(d => new + { + name = d.Name, + version = d.Version, + documentHash = d.DocumentHash, + nodes = d.Document.Nodes.Count, + edges = d.Document.Edges.Count + }))); + + // What an authoring tool calls. Validates without registering, so a document can be checked + // against the live host's catalog before anyone commits it. + app.MapPost("/dsl/validate", async (HttpRequest request, DslRegistry registry, CancellationToken ct) => + { + using var reader = new StreamReader(request.Body); + string text = await reader.ReadToEndAsync(ct).ConfigureAwait(false); + + DslParseResult result = DslParser.Parse(text, new DslEnvironment + { + CustomNodes = registry.Catalog.Describe(), + EnforceEgress = registry.EnforceEgress, + Policy = registry.Policy + }); + + return Results.Ok(new + { + valid = result.IsValid, + name = result.Document?.Name, + version = result.Document?.Version, + documentHash = result.Document?.Hash, + skippedChecks = result.Validation.SkippedChecks, + diagnostics = result.Validation.Diagnostics.Select(Describe) + }); + }); + + return app; + } + + private static object Describe(DslDiagnostic diagnostic) => new + { + code = diagnostic.Code, + severity = diagnostic.Severity == DslSeverity.Error ? "error" : "warning", + + // Empty means the document as a whole; "/" is what a pointer to the root actually looks + // like, and an editor matching on it should not have to special-case the empty string. + pointer = string.IsNullOrEmpty(diagnostic.Pointer) ? "/" : diagnostic.Pointer, + message = diagnostic.Message, + suggestion = diagnostic.Suggestion + }; + + /// Describes a document for the catalog route, when the definition is a DSL one. + public static JsonObject? DescribeSource(object? definition) + => definition is DslWorkflowDefinition dsl + ? new JsonObject + { + ["source"] = "dsl", + ["documentHash"] = dsl.DocumentHash + } + : null; +} diff --git a/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs b/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs new file mode 100644 index 0000000..795264e --- /dev/null +++ b/src/Abacus.Run.Dsl/Hosting/DslHostBuilderExtensions.cs @@ -0,0 +1,166 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Api; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Dsl.Validation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Abacus.Run.Dsl.Hosting; + +/// Registers DSL documents and custom nodes alongside compiled workflows. +public static class DslHostBuilderExtensions +{ + /// + /// Adds a document from disk. The path is read at startup, not at build time, so a document can + /// be edited and the host restarted without a rebuild. + /// + public static WorkflowHostBuilder AddDslWorkflow(this WorkflowHostBuilder builder, string path) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string full = Path.GetFullPath(path); + return builder.AddDslSource(new DslSource(full, () => File.ReadAllText(full))); + } + + /// Adds a document already in hand — an embedded resource, or a test fixture. + public static WorkflowHostBuilder AddDslWorkflowText( + this WorkflowHostBuilder builder, string text, string? description = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(text); + + return builder.AddDslSource(new DslSource(description ?? "(inline document)", () => text)); + } + + /// + /// Adds every matching document in a directory, in a stable order. + /// + /// + /// Ordered rather than left to the file system: a hash conflict between two documents claiming + /// one (name, version) should name the same one every time, or the failure would look + /// intermittent. + /// + public static WorkflowHostBuilder AddDslWorkflowsFromDirectory( + this WorkflowHostBuilder builder, + string path, + string searchPattern = "*.workflow.json", + bool recursive = false) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string full = Path.GetFullPath(path); + + if (!Directory.Exists(full)) + { + // A missing directory is a composition mistake and would otherwise register nothing at + // all, which looks exactly like a host with no workflows. + throw new DirectoryNotFoundException( + $"No DSL workflow directory at '{full}'. Check the path, or remove the registration."); + } + + IEnumerable files = Directory + .EnumerateFiles(full, searchPattern, + recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) + .OrderBy(f => f, StringComparer.Ordinal); + + foreach (string file in files) + { + builder.AddDslWorkflow(file); + } + + return builder; + } + + /// Registers a custom node, making its name available to every document. + public static WorkflowHostBuilder AddDslNode(this WorkflowHostBuilder builder, IDslNodeFactory factory) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(factory); + + Registry(builder.Services).AddNode(factory); + return builder; + } + + /// Registers a custom node from a delegate, for a node whose construction is a one-liner. + public static WorkflowHostBuilder AddDslNode( + this WorkflowHostBuilder builder, + string name, + Func create, + JsonNode? parameterSchema = null) + => builder.AddDslNode(new DelegateDslNodeFactory(name, create, parameterSchema)); + + /// + /// Makes the DSL available without registering any document: the node catalog, the schema route + /// and the validate route all work on a host that has not yet been given one. + /// + /// + /// Needed because MapDslApi resolves the registry, and a host that maps the routes before + /// anyone has called AddDslWorkflow would otherwise fail to start. Which is exactly the + /// host an authoring tool talks to while a first document is being written. + /// + public static WorkflowHostBuilder UseDsl(this WorkflowHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + Registry(builder.Services); + return builder; + } + + /// Configures the limits and egress policy documents are validated against. + public static WorkflowHostBuilder ConfigureDsl( + this WorkflowHostBuilder builder, Action configure) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + configure(Registry(builder.Services)); + return builder; + } + + private static WorkflowHostBuilder AddDslSource(this WorkflowHostBuilder builder, DslSource source) + { + DslRegistry registry = Registry(builder.Services); + int index = registry.AddSource(source); + + // One IWorkflowDefinition registration per document, all resolving from the same shared + // list. Registering the list as a single service would hide the documents from the registry, + // which enumerates IWorkflowDefinition to build the catalog. + // + // The factory runs on first enumeration — after every AddDslNode call has completed, which + // is what lets composition be written in any order. + builder.Services.AddSingleton(_ => + { + IReadOnlyList definitions = registry.Resolve(); + + return index < definitions.Count + ? definitions[index] + : throw new InvalidOperationException( + $"The DSL document '{source.Description}' did not resolve to a definition."); + }); + + return builder; + } + + /// + /// One registry per service collection, held as a singleton instance so both the composition + /// calls and the container see the same object. + /// + private static DslRegistry Registry(IServiceCollection services) + { + ServiceDescriptor? existing = services.FirstOrDefault( + d => d.ServiceType == typeof(DslRegistry) && d.ImplementationInstance is DslRegistry); + + if (existing?.ImplementationInstance is DslRegistry registry) + { + return registry; + } + + var created = new DslRegistry(); + services.AddSingleton(created); + services.TryAddSingleton(created.Catalog); + return created; + } +} diff --git a/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs b/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs new file mode 100644 index 0000000..dc44e1d --- /dev/null +++ b/src/Abacus.Run.Dsl/Hosting/DslRegistry.cs @@ -0,0 +1,157 @@ +using Abacus.Run.Abstractions; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Dsl.Validation; + +namespace Abacus.Run.Dsl.Hosting; + +/// Where a document came from, for naming it in a diagnostic. +public sealed record DslSource(string Description, Func Read); + +/// +/// Collects DSL documents and custom node registrations during composition, then resolves them once +/// the container is built. +/// +/// +/// +/// Resolution is deferred for one reason: AddDslWorkflow and AddDslNode can be called +/// in either order, and a document must be validated against the complete node catalog. +/// Validating a document the moment it is added would make correctness depend on the order the +/// composition happened to be written in. +/// +/// +/// A document that fails validation throws here, which surfaces as a startup failure — the same +/// place a bad compiled workflow fails, and for the same reason. +/// +/// +public sealed class DslRegistry +{ + private readonly List _sources = []; + private readonly DslNodeCatalog _catalog = new(); + private readonly Lock _gate = new(); + + private IReadOnlyList? _resolved; + + public DslPolicy Policy { get; set; } = DslPolicy.Default; + + /// Whether http nodes must declare an allow-list. Mirrors the host's egress setting. + public bool EnforceEgress { get; set; } = true; + + public TimeProvider Clock { get; set; } = TimeProvider.System; + + public DslNodeCatalog Catalog => _catalog; + + /// + /// Adds a document and returns its position, which is also its position in the resolved list — + /// validation either succeeds for every source or throws, so the two stay one to one. + /// + public int AddSource(DslSource source) + { + ArgumentNullException.ThrowIfNull(source); + + lock (_gate) + { + if (_resolved is not null) + { + throw new InvalidOperationException( + "DSL documents cannot be added after the registry has been resolved."); + } + + _sources.Add(source); + return _sources.Count - 1; + } + } + + public DslRegistry AddNode(IDslNodeFactory factory) + { + lock (_gate) + { + if (_resolved is not null) + { + throw new InvalidOperationException( + "DSL nodes cannot be registered after the registry has been resolved."); + } + + _catalog.Add(factory); + } + + return this; + } + + /// + /// Parses and validates every document against the complete catalog, once. Later calls return the + /// same definitions — the registry is read at startup and never again. + /// + public IReadOnlyList Resolve() + { + lock (_gate) + { + if (_resolved is not null) + { + return _resolved; + } + + var definitions = new List(_sources.Count); + var published = new Dictionary(StringComparer.OrdinalIgnoreCase); + var failures = new List(); + + foreach (DslSource source in _sources) + { + var environment = new DslEnvironment + { + CustomNodes = _catalog.Describe(), + EnforceEgress = EnforceEgress, + + // Accumulated as documents resolve, so a second document claiming a published + // (name, version) with different content is caught here rather than by the + // registry's duplicate-version check, which cannot say why they differ. + PublishedHashes = published, + Policy = Policy + }; + + DslParseResult result; + try + { + result = DslParser.Parse(source.Read(), environment); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + failures.Add($"{source.Description}: {ex.Message}"); + continue; + } + + if (!result.IsValid) + { + failures.Add($"{source.Description}:{Environment.NewLine}{result.Validation.Describe()}"); + continue; + } + + DslDocument document = result.Document!; + published[$"{document.Name}@{document.Version}"] = document.Hash; + definitions.Add(DslWorkflowDefinition.Create(document, _catalog, Clock)); + } + + if (failures.Count > 0) + { + // Every failure, not the first. A composition with three broken documents should take + // one startup to fix, not three. + throw new DslValidationException( + $"{failures.Count} DSL document(s) failed validation:{Environment.NewLine}{Environment.NewLine}" + + string.Join(Environment.NewLine + Environment.NewLine, failures), + DslValidationResult.Empty); + } + + _resolved = definitions; + return _resolved; + } + } + + /// The registered documents, for the catalog endpoint. Resolves if it has not already. + public IReadOnlyList Definitions => Resolve(); + + /// Looks up a definition by name and version, for reporting a document's hash. + public DslWorkflowDefinition? Find(string name, string? version = null) + => Resolve().FirstOrDefault(d => + string.Equals(d.Name, name, StringComparison.OrdinalIgnoreCase) && + (version is null || string.Equals(d.Version, version, StringComparison.OrdinalIgnoreCase))); +} diff --git a/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs new file mode 100644 index 0000000..9cee0a5 --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs @@ -0,0 +1,451 @@ +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Executors; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Abacus.Run.Dsl.Interpretation; + +/// +/// Opens the envelope. The workflow's declared input is a — that is what +/// the runner deserializes the stored context into and sends as the first message — while every DSL +/// node speaks . +/// +/// +/// A real node rather than a conversion hidden inside the first one: the engine routes by message +/// type, so without something typed to accept the context the first node simply never receives it +/// and the run completes having done nothing at all. +/// +internal sealed class DslEntryExecutor : HostExecutor +{ + /// + /// Cannot collide with a declared node id: those must match ^[a-z][a-z0-9-]{0,63}$, which + /// forbids a leading dollar. + /// + internal const string NodeId = "$entry"; + + internal DslEntryExecutor() : base(NodeId) { } + + public override IReadOnlyDictionary Metadata => + new Dictionary { ["node.kind"] = "entry" }; + + protected override ValueTask ExecuteCoreAsync( + JsonElement input, IWorkflowContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(DslMessage.Start(input)); +} + +/// +/// Closes the envelope: the workflow's result is the payload, not the envelope that carried it. +/// +/// +/// +/// A node rather than a YieldOutputAsync call inside each output node, because the engine +/// checks a yielded value against the executor's declared output type — a DSL node declares +/// and may not yield anything else. +/// +/// +/// Without it the caller would get back the whole envelope, including the start context every +/// message carries so that expressions can reach it. That context is machinery, not a result. +/// +/// +internal sealed class DslExitExecutor : HostExecutor +{ + internal const string NodeId = "$exit"; + + internal DslExitExecutor() : base(NodeId) { } + + public override IReadOnlyDictionary Metadata => + new Dictionary { ["node.kind"] = "exit" }; + + protected override ValueTask ExecuteCoreAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(Unwrap(input.Data)); + + /// + /// Payload data is an object in every shape the built-in nodes produce. A document that ends on + /// something else still gets a result rather than a failure, wrapped so the shape is predictable. + /// + internal static JsonObject Unwrap(JsonNode? data) => data switch + { + JsonObject obj => (JsonObject)obj.DeepClone(), + null => [], + _ => new JsonObject { ["value"] = data.DeepClone() } + }; +} + +/// Pure projection. The only node that computes, and it computes only through AbEx. +internal sealed class DslTransformExecutor : DslExecutor +{ + private readonly DslTransformNode _node; + + internal DslTransformExecutor(DslTransformNode node, TimeProvider? clock = null) : base(node, clock) + => _node = node; + + protected override ValueTask RunAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + JsonNode? data = DslExpressions.ApplySet(_node.Set, Context(input), input.Data, _node.Replace); + return ValueTask.FromResult(input.WithData(data)); + } +} + +/// +/// Aggregates a fan-in barrier's inputs. The one node whose input is not a bare envelope, because the +/// engine delivers a barrier's messages as a list. +/// +internal sealed class DslFanInExecutor : DslExecutor +{ + private readonly DslFanInNode _node; + private readonly int _expected; + private readonly List _arrived = []; + private readonly Lock _gate = new(); + + internal DslFanInExecutor(DslFanInNode node, int expectedSources, TimeProvider? clock = null) + : base(node, clock) + { + _node = node; + _expected = Math.Max(1, expectedSources); + } + + protected override ValueTask RunAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + // A barrier releases its held messages together, but the engine still delivers them one at a + // time — it type-checks the target against the individual message, not against a list. So the + // aggregation lives here: hold each arrival, and emit once the last one lands. + JsonArray? items = null; + + lock (_gate) + { + _arrived.Add(input.Data?.DeepClone()); + + if (_arrived.Count >= _expected) + { + items = [.. _arrived]; + _arrived.Clear(); + } + } + + if (items is null) + { + // Not the last arrival. Returning null emits nothing — the same mechanism a gated node + // uses to stay silent, without requesting a halt. + return ValueTask.FromResult(null); + } + + var data = new JsonObject(); + DslExpressions.Assign(data, _node.Into, items); + + // The context is identical on every branch by construction — it is frozen at start — so + // continuing with this arrival's envelope is not a choice between differing values. + return ValueTask.FromResult(input.WithData(data)); + } +} + +/// +/// Builds the executor for each built-in kind. +/// +/// +/// Every one of these maps onto an executor the host already ships. Nothing here reimplements HTTP, +/// prompting, egress control, idempotency keys, durable waits or cost accounting — the DSL is a +/// front end, and a front end that forked the execution path would stop being one. +/// +internal static class DslBuiltInNodes +{ + internal static IHostExecutor Create(DslNodeContext context, DslNodeCatalog catalog, TimeProvider clock) + => context.Node switch + { + DslTransformNode node => new DslTransformExecutor(node, clock), + DslFanInNode node => new DslFanInExecutor(node, BarrierSourceCount(context.Document, node.Id), clock), + DslApprovalNode node => Approval(node, context, clock), + DslHttpNode node => Http(node, context, clock), + DslLlmNode node => Llm(node, context, clock), + DslDelayNode node => Delay(node, context, clock), + DslPublishNode node => Publish(node, context, clock), + DslWaitEventNode node => WaitEvent(node, context, clock), + DslCustomNode node => Custom(node, context, catalog, clock), + _ => throw new NotSupportedException( + $"Node '{context.Node.Id}' has kind '{context.Node.Kind}', which the interpreter does not build.") + }; + + /// + /// How many messages a barrier will release into this node. Read from the document rather than + /// counted at run time, because the node has to know when it has them all before the last one + /// arrives. + /// + private static int BarrierSourceCount(Model.DslDocument document, string nodeId) + => document.Edges + .Where(e => e.IsBarrier && e.To.Contains(nodeId, StringComparer.Ordinal)) + .Sum(e => e.From.Count); + + /// + /// Identity work. The pause comes from the gate the factory guarantees, so the node is visible in + /// the graph as the place a human decides rather than as configuration on some other node. + /// + private static IHostExecutor Approval(DslApprovalNode node, DslNodeContext context, TimeProvider clock) + { + var inner = new HumanApprovalExecutor(node.Id); + + return new DslHostedExecutor( + node, inner, inner.ExecuteTerminalAsync, + static (output, _) => (DslMessage)output, clock); + } + + private static IHostExecutor Http(DslHttpNode node, DslNodeContext context, TimeProvider clock) + { + var options = new ApiCallOptions + { + Method = new HttpMethod(node.Method), + UrlTemplate = node.Url, + Headers = new Dictionary(node.Headers), + BodyTemplate = node.Body, + TimeoutSeconds = node.TimeoutSeconds, + AllowedHosts = [.. node.AllowedHosts], + SendIdempotencyKey = node.SendIdempotencyKey + }; + + if (node.SuccessCodes.Count > 0) + { + options.SuccessCodes = [.. node.SuccessCodes]; + } + + IHttpClientFactory? factory = context.Optional(); + Func clientFactory = factory is null + ? static () => new HttpClient() + : () => factory.CreateClient(ApiCallOptions.HttpClientName); + + var inner = new ApiCallExecutor(node.Id, options, clientFactory); + + return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, ProjectHttp, clock); + } + + /// + /// Status alongside body, so a document can route on either. Both are needed: the body carries + /// the answer, and the status is how a workflow tells "found nothing" from "not found". + /// + private static DslMessage ProjectHttp(object output, DslMessage input) + { + var result = (ApiCallResult)output; + + return input.WithData(new JsonObject + { + ["status"] = result.StatusCode, + ["body"] = ToJson(result.Body, result.RawBody) + }); + } + + private static JsonNode? ToJson(object? body, string? raw) + { + if (body is not null and not string) + { + return JsonSerializer.SerializeToNode(body, JsonOptions.Default); + } + + string? text = body as string ?? raw; + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + // A JSON response is far more useful addressable than as a string, and a non-JSON one + // must not fail the node for being what it always was. + return JsonNode.Parse(text); + } + catch (JsonException) + { + return JsonValue.Create(text); + } + } + + private static IHostExecutor Llm(DslLlmNode node, DslNodeContext context, TimeProvider clock) + { + var options = new LlmOptions + { + Model = node.Model, + SystemPrompt = node.System, + UserTemplate = node.Prompt, + PromptVersion = node.PromptVersion, + Temperature = node.Temperature, + MaxTokens = node.MaxTokens, + StreamDeltas = node.StreamDeltas, + EmitCompletion = node.EmitCompletion + }; + + var resolver = context.Optional(); + Func clientResolver = resolver is not null + ? resolver.Resolve + : _ => context.Require(); + + var inner = new LlmExecutor(node.Id, options, clientResolver, context.Optional()); + + return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, ProjectLlm, clock); + } + + /// + /// Text and the parsed value, plus the numbers a run is judged by. Cost and tokens are on the + /// envelope as well as in the log because a document may want to branch on them. + /// + private static DslMessage ProjectLlm(object output, DslMessage input) + { + var result = (LlmResult)output; + + return input.WithData(new JsonObject + { + ["text"] = result.Text, + ["value"] = result.Value is null or string + ? JsonValue.Create(result.Text) + : JsonSerializer.SerializeToNode(result.Value, JsonOptions.Default), + ["model"] = result.ModelId, + ["inputTokens"] = result.InputTokens, + ["outputTokens"] = result.OutputTokens, + ["costUsd"] = result.CostUsd, + ["finishReason"] = result.FinishReason, + ["elapsedMs"] = (long)result.Elapsed.TotalMilliseconds + }); + } + + private static IHostExecutor Delay(DslDelayNode node, DslNodeContext context, TimeProvider clock) + { + var inner = new DelayExecutor(node.Id, node.For, context.Require(), clock); + + // The envelope passes through: a delay is about when the next node runs, not about changing + // what it receives, and losing the payload to a TimerElapsed record would make every delay + // need a transform after it. + return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, + static (_, input) => input, clock); + } + + private static IHostExecutor Publish(DslPublishNode node, DslNodeContext context, TimeProvider clock) + { + var broker = context.Require(); + + DeliveryScope scope = string.Equals(node.Scope, "distributed", StringComparison.Ordinal) + ? DeliveryScope.Distributed + : DeliveryScope.Local; + + var inner = new PublishDomainEventExecutor( + node.Id, + broker, + node.Topic, + payload: message => BuildPayload(node, message), + correlationKey: message => node.CorrelationKey is null + ? null + : DslExpressions.Text(node.CorrelationKey, message.ToExpressionContext(message.Run)), + scope: scope, + clock: clock); + + // Publishing passes its input through, so the envelope continues unchanged. + return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, + static (output, _) => (DslMessage)output, clock); + } + + /// + /// An explicit payload map, or the whole of data. Defaulting to the payload rather than + /// the envelope matters: a subscriber should receive the message, not this workflow's context. + /// + private static object BuildPayload(DslPublishNode node, DslMessage message) + { + if (node.Payload.Count == 0) + { + return message.Data ?? (JsonNode)new JsonObject(); + } + + AbExContext context = message.ToExpressionContext(message.Run); + var payload = new JsonObject(); + + foreach ((string key, string expression) in node.Payload) + { + AbExValue value = DslExpressions.Evaluate(expression, context); + payload[key] = value.IsAbsent ? null : value.ToNode(); + } + + return payload; + } + + private static IHostExecutor WaitEvent(DslWaitEventNode node, DslNodeContext context, TimeProvider clock) + { + WaitExpiryAction onExpiry = string.Equals(node.OnExpiry, "resume", StringComparison.Ordinal) + ? WaitExpiryAction.Resume + : WaitExpiryAction.DeadStop; + + var inner = new WaitForDomainEventExecutor( + node.Id, + context.Require(), + node.Topic, + correlationKey: message => node.CorrelationKey is null + ? null + : DslExpressions.Text(node.CorrelationKey, message.ToExpressionContext(message.Run)), + timeout: node.Timeout, + onExpiry: onExpiry, + clock: clock); + + // Runs twice: the first pass registers the wait and parks (null output, propagated by the + // hosted executor), the second finds the delivered payload and returns it as the new data. + return new DslHostedExecutor(node, inner, inner.ExecuteTerminalAsync, + static (output, input) => input.WithData((JsonNode)output), clock); + } + + private static IHostExecutor Custom( + DslCustomNode node, DslNodeContext context, DslNodeCatalog catalog, TimeProvider clock) + { + if (!catalog.TryGet(node.NodeName, out IDslNodeFactory factory)) + { + // Validation refuses this at registration, so reaching here means the catalog changed + // underneath a document that was already accepted. + throw new InvalidOperationException( + $"Node '{node.Id}' names custom node '{node.NodeName}', which is not registered. " + + $"Known: {(catalog.Names.Count == 0 ? "(none)" : string.Join(", ", catalog.Names))}."); + } + + IHostExecutor executor = factory.Create(context) + ?? throw new InvalidOperationException( + $"The factory for custom node '{node.NodeName}' returned null for node '{node.Id}'."); + + if (executor.InputType != typeof(DslMessage) || executor.OutputType != typeof(DslMessage)) + { + // Every edge in a DSL graph carries the envelope. A node emitting anything else breaks + // the next edge rather than its own, so it is refused where the mistake was made. + throw new InvalidOperationException( + $"Custom node '{node.NodeName}' produced an executor of " + + $"{executor.InputType.Name} -> {executor.OutputType.Name}. " + + $"A DSL node must be HostExecutor<{nameof(DslMessage)}, {nameof(DslMessage)}>."); + } + + if (!string.Equals(executor.Id, node.Id, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Custom node '{node.NodeName}' produced an executor with id '{executor.Id}', " + + $"but the document declared '{node.Id}'. Gate policy and node state key off the " + + "declared id, so they must match."); + } + + // Hosted rather than returned bare, so a custom node gets everything a built-in one gets: + // the bound expression roots, its declared notification, and the output yield. A factory + // author writes ExecuteCoreAsync and nothing else. + var typed = (HostExecutor)executor; + + return new DslHostedExecutor( + node, typed, typed.ExecuteTerminalAsync, + static (output, _) => (DslMessage)output, clock); + } +} + +/// +/// Resolves a chat client by model name. +/// +/// +/// A document names its model as a string, and a host serving several models needs some way to map +/// that to a client. Optional: a host with one model registers an and +/// nothing else. +/// +public interface IChatClientResolver +{ + IChatClient Resolve(string model); +} diff --git a/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs b/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs new file mode 100644 index 0000000..8a8d968 --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/DslExecutor.cs @@ -0,0 +1,171 @@ +using System.Runtime.ExceptionServices; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Abstractions.Middleware; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Model; +using Microsoft.Agents.AI.Workflows; + +namespace Abacus.Run.Dsl.Interpretation; + +/// +/// Base for every DSL node. Binds the expression roots, applies the declared notification, and keeps +/// the park path intact. +/// +/// +/// is sealed so no node can skip the envelope handling. A subclass +/// implements and returns null to park, exactly as the framework's own +/// approval and event-wait executors do. +/// +public abstract class DslExecutor : HostExecutor +{ + private readonly TimeProvider _clock; + + protected DslExecutor(DslNode node, TimeProvider? clock = null) : base(node.Id) + { + Node = node; + _clock = clock ?? TimeProvider.System; + } + + protected DslNode Node { get; } + + public override IReadOnlyDictionary Metadata => new Dictionary + { + ["node.kind"] = Node.Kind, + ["dsl.node"] = Node.Id + }; + + protected sealed override async ValueTask ExecuteCoreAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + DslMessage bound = Bind(input); + + DslMessage? result = await RunAsync(bound, context, cancellationToken).ConfigureAwait(false); + + // Null is the park signal — the engine declines to send a null handler result, which is what + // lets a gated or waiting node halt without emitting a bogus message downstream. + if (result is null) + { + return null!; + } + + await NotifyAsync(result, cancellationToken).ConfigureAwait(false); + return result; + } + + /// The node's own work. Return null to park the instance. + protected abstract ValueTask RunAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken); + + /// + /// Refreshes $run and meta for this hop. Rebuilt per node rather than carried, + /// because superstep and attempt are the two things that change as a run proceeds. + /// + protected DslMessage Bind(DslMessage input) + { + JsonObject run = DslMessage.RunMetadata( + Runtime.InstanceId, + Runtime.TenantId, + Runtime.Descriptor.WorkflowName, + Runtime.Descriptor.WorkflowVersion, + Runtime.Attempt, + Runtime.CurrentSuperstep, + _clock.GetUtcNow()); + + return input + .WithRun(run) + .WithMeta(new DslMeta(Id, Runtime.CurrentSuperstep, Runtime.Attempt)); + } + + protected AbExContext Context(DslMessage message) => message.ToExpressionContext(message.Run); + + private async ValueTask NotifyAsync(DslMessage result, CancellationToken cancellationToken) + { + if (Node.Notify is not { } notify || Runtime.Notify is not { } notifier) + { + return; + } + + var payload = new JsonObject(); + AbExContext context = Context(result); + + foreach ((string key, string expression) in notify.Payload) + { + AbExValue value = DslExpressions.Evaluate(expression, context); + if (!value.IsAbsent) + { + payload[key] = value.ToNode(); + } + } + + await notifier.NotifyAsync(notify.Name, payload, cancellationToken).ConfigureAwait(false); + } +} + +/// +/// Hosts one of the framework's own executors inside a DSL node. +/// +/// +/// +/// The built-in executors are typed to their own inputs and outputs — ApiCallResult, +/// LlmResult, TimerElapsed — which is exactly what the uniform envelope cannot carry. +/// Rather than reimplement any of them, this calls +/// on the inner executor and projects the +/// result back into the envelope. No HTTP, egress, idempotency, prompt or cost logic is duplicated. +/// +/// +/// The inner executor's own gate and middleware pipeline are deliberately bypassed: this node has +/// already run both, and running them twice would double every middleware and evaluate the gate +/// against an input that has already passed it. +/// +/// +internal sealed class DslHostedExecutor : DslExecutor +{ + private readonly IHostExecutor _inner; + private readonly Func _invoke; + private readonly Func _project; + + internal DslHostedExecutor( + DslNode node, + IHostExecutor inner, + Func invoke, + Func project, + TimeProvider? clock = null) : base(node, clock) + { + _inner = inner; + _invoke = invoke; + _project = project; + } + + protected override async ValueTask RunAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + // The inner executor reads the instance, tenant, attempt and notifier off its runtime; giving + // it this node's means an idempotency key or an llm.completed event is attributed here. + _inner.Runtime = Runtime; + + var invocation = new ExecutorInvocationContext + { + InstanceId = Runtime.InstanceId, + Descriptor = Runtime.Descriptor, + Superstep = Runtime.CurrentSuperstep, + Attempt = Runtime.Attempt, + Input = input, + WorkflowContext = context, + Services = Runtime.Services + }; + + await _invoke(invocation, cancellationToken).ConfigureAwait(false); + + if (invocation.Exception is { } exception) + { + // Preserves the original stack so the document's failure rules classify the real fault. + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + // A null output from the inner executor means it parked — an event wait registering its + // subscription, for instance. Propagated rather than wrapped, or the park would be undone by + // an envelope the engine would happily send onward. + return invocation.Output is null ? null : _project(invocation.Output, input); + } +} diff --git a/src/Abacus.Run.Dsl/Interpretation/DslExpressions.cs b/src/Abacus.Run.Dsl/Interpretation/DslExpressions.cs new file mode 100644 index 0000000..e36c15f --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/DslExpressions.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using Abacus.Run.Dsl.Expressions; + +namespace Abacus.Run.Dsl.Interpretation; + +/// +/// Parses expressions once and evaluates them many times. +/// +/// +/// BuildAsync runs once per attempt and has to stay cheap, so nothing here re-parses. The +/// cache is process-wide and keyed by expression text: two documents using $.total > 0 +/// share one tree, and a tree is immutable so sharing is safe. +/// +public static class DslExpressions +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + /// + /// Returns the parsed tree, or null when the text does not parse. Null rather than throwing + /// because validation has already had its chance to report the problem properly; a parse failure + /// here means something bypassed it, and a run should degrade rather than crash. + /// + public static AbExNode? Tree(string expression) + => Cache.GetOrAdd(expression, static text => AbExParser.Parse(text).Node); + + public static AbExValue Evaluate(string expression, AbExContext context) + { + AbExNode? tree = Tree(expression); + return tree is null ? AbExValue.Absent : AbExEvaluator.Evaluate(tree, context); + } + + /// Strict boolean evaluation: anything that is not true is false. + public static bool Condition(string expression, AbExContext context) + => Evaluate(expression, context).IsTruthy; + + public static JsonNode? Node(string expression, AbExContext context) + => Evaluate(expression, context).ToNode(); + + public static string? Text(string expression, AbExContext context) + { + AbExValue value = Evaluate(expression, context); + return value.IsAbsent ? null : value.ToText(); + } + + /// + /// Applies a set map to a value. Every expression reads the value as it was before + /// the transform, so the order the properties happen to be written in cannot change the result. + /// + public static JsonNode? ApplySet( + IReadOnlyDictionary set, AbExContext context, JsonNode? current, bool replace) + { + JsonObject target = replace || current is not JsonObject existing + ? [] + : (JsonObject)existing.DeepClone(); + + foreach ((string path, string expression) in set) + { + AbExValue value = Evaluate(expression, context); + Assign(target, path, value.IsAbsent ? null : value.ToNode()); + } + + return target; + } + + /// + /// Writes to a dotted path, creating intermediate objects. A segment that exists but is not an + /// object is replaced rather than merged into: the document asked for a property there. + /// + internal static void Assign(JsonObject root, string path, JsonNode? value) + { + string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + return; + } + + JsonObject current = root; + + for (int i = 0; i < segments.Length - 1; i++) + { + if (current[segments[i]] is JsonObject child) + { + current = child; + continue; + } + + var created = new JsonObject(); + current[segments[i]] = created; + current = created; + } + + current[segments[^1]] = value; + } +} diff --git a/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs b/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs new file mode 100644 index 0000000..0c50eb1 --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs @@ -0,0 +1,491 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Dsl.Validation; +using Json.Schema; +using Microsoft.Agents.AI.Workflows; + +namespace Abacus.Run.Dsl.Interpretation; + +/// +/// A DSL document, registered and executed as an ordinary workflow definition. +/// +/// +/// +/// The document is parsed and validated once, at registration. runs per +/// attempt and only walks the model, constructing executors and edges — it never re-parses, never +/// re-validates, and never reads the file again. +/// +/// +/// Notifications and triggers are implemented unconditionally because their "nothing declared" +/// answers — the default policy and an empty trigger list — are exactly what a definition that did +/// not implement them would produce. An audit record is not like that: implementing +/// IAuditedWorkflowDefinition gives every run a record, so that one is a separate type. +/// +/// +public class DslWorkflowDefinition + : IWorkflowDefinition, + IContextValidatingWorkflow, + INotifyingWorkflow, + IDomainEventTriggeredWorkflow +{ + private readonly DslNodeCatalog _catalog; + private readonly TimeProvider _clock; + private readonly JsonSchema? _contextSchema; + private readonly NotificationPolicy _notifications; + private readonly IReadOnlyList _triggers; + + internal DslWorkflowDefinition(DslDocument document, DslNodeCatalog catalog, TimeProvider? clock = null) + { + Document = document ?? throw new ArgumentNullException(nameof(document)); + _catalog = catalog ?? new DslNodeCatalog(); + _clock = clock ?? TimeProvider.System; + + _contextSchema = document.ContextSchema is null + ? null + : JsonSchema.FromText(document.ContextSchema.ToJsonString()); + + _notifications = BuildNotificationPolicy(document); + _triggers = BuildTriggers(document); + } + + /// Builds the definition, choosing the audited variant when the document declares one. + public static DslWorkflowDefinition Create( + DslDocument document, DslNodeCatalog? catalog = null, TimeProvider? clock = null) + { + ArgumentNullException.ThrowIfNull(document); + + return document.Audit is null + ? new DslWorkflowDefinition(document, catalog ?? new DslNodeCatalog(), clock) + : new DslAuditedWorkflowDefinition(document, catalog ?? new DslNodeCatalog(), clock); + } + + public DslDocument Document { get; } + + public string Name => Document.Name; + + public string Version => Document.Version; + + /// The canonical hash of the source document. Identity, and drift detection. + public string DocumentHash => Document.Hash; + + public NotificationPolicy Notifications => _notifications; + + public IReadOnlyList Triggers => _triggers; + + // ---- context validation ------------------------------------------------------------------ + + /// + /// Validates the start payload against the document's declared schema. The registry's own check + /// binds to , which never fails and therefore never says anything. + /// + public ContextValidationResult ValidateContext(JsonElement context) + { + if (_contextSchema is null) + { + return ContextValidationResult.Valid; + } + + JsonNode? node = JsonSerializer.Deserialize(context); + + EvaluationResults results = _contextSchema.Evaluate(node, new EvaluationOptions + { + OutputFormat = OutputFormat.List + }); + + if (results.IsValid) + { + return ContextValidationResult.Valid; + } + + var errors = new Dictionary(StringComparer.Ordinal); + + foreach (EvaluationResults detail in Flatten(results).Where(r => r.Errors is { Count: > 0 })) + { + // Keyed by instance location so a caller can put each message beside the field it is + // about, which is what a form needs and a flat list is not. + string field = detail.InstanceLocation.ToString() is { Length: > 0 } location + ? location.TrimStart('/').Replace('/', '.') + : "context"; + + string[] messages = [.. detail.Errors!.Values]; + errors[field] = errors.TryGetValue(field, out string[]? existing) + ? [.. existing, .. messages] + : messages; + } + + if (errors.Count == 0) + { + errors["context"] = ["The context does not match the workflow's declared schema."]; + } + + return new ContextValidationResult(false, errors); + } + + private static IEnumerable Flatten(EvaluationResults results) + { + yield return results; + + foreach (EvaluationResults detail in results.Details) + { + foreach (EvaluationResults nested in Flatten(detail)) + { + yield return nested; + } + } + } + + // ---- graph ------------------------------------------------------------------------------- + + public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + + var bindings = new Dictionary(StringComparer.Ordinal); + HashSet outputs = OutputNodeIds(); + + foreach (DslNode node in Document.Nodes) + { + IHostExecutor executor = DslBuiltInNodes.Create( + new DslNodeContext(node, Document, context, outputs.Contains(node.Id)), + _catalog, _clock); + + bindings[node.Id] = context.Node(executor, GateFor(node)); + } + + if (!bindings.TryGetValue(Document.Start, out ExecutorBinding? start)) + { + throw new InvalidOperationException( + $"Workflow '{Name}' starts at '{Document.Start}', which is not one of its nodes. " + + "The document should not have registered."); + } + + // The graph begins at the entry node, not at the document's start node: the first message + // the engine sends is the deserialized context, and only the entry node is typed to receive + // it. Its single edge hands the opened envelope to the declared start. + ExecutorBinding entry = context.Node(new DslEntryExecutor()); + + var builder = new WorkflowBuilder(entry); + builder.AddEdge(entry, start); + + foreach (DslEdge edge in Document.Edges) + { + AddEdge(builder, edge, bindings); + } + + // Every output node feeds the exit node, and the exit node is what the result is bound to. + // The declared output nodes produce envelopes; the caller gets the payload. + ExecutorBinding exit = context.Node(new DslExitExecutor()); + + foreach (string id in outputs.Where(bindings.ContainsKey)) + { + builder.AddEdge(bindings[id], exit); + } + + builder.WithOutputFrom(exit); + + return new ValueTask(builder + .WithName(Name) + .WithDescription(Document.Description ?? string.Empty) + .Build()); + } + + /// + /// The nodes whose payload is the workflow's result: those the document declared, or every + /// terminal node when it declared none. + /// + private HashSet OutputNodeIds() + { + if (Document.Output.Count > 0) + { + return [.. Document.Output]; + } + + var hasOutgoing = new HashSet( + Document.Edges.SelectMany(e => e.From), StringComparer.Ordinal); + + return [.. Document.Nodes.Select(n => n.Id).Where(id => !hasOutgoing.Contains(id))]; + } + + private void AddEdge( + WorkflowBuilder builder, DslEdge edge, IReadOnlyDictionary bindings) + { + if (edge.IsBarrier) + { + builder.AddFanInBarrierEdge( + [.. edge.From.Select(id => bindings[id])], bindings[edge.To[0]], edge.Label); + return; + } + + ExecutorBinding source = bindings[edge.From[0]]; + + if (edge.IsFanOut) + { + ExecutorBinding[] targets = [.. edge.To.Select(id => bindings[id])]; + + if (edge.Select is { Length: > 0 } select) + { + builder.AddFanOutEdge(source, targets, + targetSelector: (message, count) => SelectTargets(select, message, count), + label: edge.Label); + return; + } + + if (edge.Label is { Length: > 0 } fanOutLabel) + { + builder.AddFanOutEdge(source, targets, fanOutLabel); + return; + } + + builder.AddFanOutEdge(source, targets); + return; + } + + ExecutorBinding target = bindings[edge.To[0]]; + + if (edge.When is { Length: > 0 } when) + { + builder.AddEdge(source, target, + condition: message => message is not null && + DslExpressions.Condition(when, message.ToExpressionContext(message.Run)), + label: edge.Label, + idempotent: edge.Idempotent); + return; + } + + builder.AddEdge(source, target, edge.Label, edge.Idempotent); + } + + /// + /// Resolves a fan-out selector to target indices. A number picks one target, an array picks + /// several; anything else picks none, which stops the branch rather than failing the run. + /// + private static IEnumerable SelectTargets(string select, DslMessage? message, int count) + { + if (message is null) + { + return []; + } + + AbExValue value = DslExpressions.Evaluate(select, message.ToExpressionContext(message.Run)); + + IEnumerable indices = value.Kind switch + { + AbExValueKind.Number => [(int)value.AsNumber], + AbExValueKind.Array => ((JsonArray)value.AsNode!) + .Select(n => n is JsonValue v && v.TryGetValue(out int i) ? i : -1), + _ => [] + }; + + return indices.Where(i => i >= 0 && i < count).Distinct(); + } + + // ---- gates ------------------------------------------------------------------------------- + + private static Action? GateFor(DslNode node) + { + // An approval node is the gate: it exists to be the place a human decides, so it carries one + // whether or not the document spelled the block out. + DslGate? gate = node.Gate ?? (node is DslApprovalNode + ? new DslGate { Mode = "requireApproval" } + : null); + + if (gate is null || string.Equals(gate.Mode, "autonomous", StringComparison.Ordinal)) + { + return null; + } + + return builder => + { + if (string.Equals(gate.Mode, "conditional", StringComparison.Ordinal) && + gate.When is { Length: > 0 } when) + { + builder.WhenAsync(input => new ValueTask( + input is DslMessage message && + DslExpressions.Condition(when, message.ToExpressionContext(message.Run)))); + } + else + { + builder.Mode(ExecutionMode.RequireApproval); + } + + if (gate.Reason is { Length: > 0 } reason) + { + builder.Reason(reason); + } + + if (gate.AssignTo.Count > 0) + { + builder.AssignTo([.. gate.AssignTo]); + } + + builder.RequireApprovers(gate.RequireApprovers); + builder.ExpiresAfter(gate.ExpiresAfter); + builder.OnExpiry(ExpiryActionOf(gate.OnExpiryAction), [.. gate.EscalateTo]); + builder.AllowModification(gate.AllowModification); + builder.RequireSegregationOfDuties(gate.RequireSegregationOfDuties); + builder.Locked(gate.Locked); + }; + } + + private static ExpiryAction ExpiryActionOf(string action) => action switch + { + "reject" => ExpiryAction.Reject, + "autoApprove" => ExpiryAction.AutoApprove, + "escalate" => ExpiryAction.Escalate, + _ => ExpiryAction.DeadStop + }; + + // ---- failure classification ----------------------------------------------------------------- + + /// + /// Applies the document's rules in order, then defers. Deferring rather than defaulting to retry + /// matters: the framework's classifier already knows that a rate limit is worth retrying and a + /// validation error is not, and a document should only have to state where it disagrees. + /// + public FailureDisposition Classify(WorkflowFailure failure) + { + ArgumentNullException.ThrowIfNull(failure); + + foreach (DslFailureRule rule in Document.OnFailure.Where(r => Matches(r, failure))) + { + return rule.Disposition switch + { + "deadStop" => FailureDisposition.DeadStop, + "escalate" => FailureDisposition.Escalate, + _ => FailureDisposition.Retry + }; + } + + return DefaultFailureClassifier.Instance.Classify(failure); + } + + private static bool Matches(DslFailureRule rule, WorkflowFailure failure) + { + if (rule.Node is { Length: > 0 } node && + !string.Equals(node, failure.ExecutorId, StringComparison.Ordinal)) + { + return false; + } + + if (rule.Exception is { Length: > 0 } exception && + !string.Equals(failure.Exception?.GetType().Name, exception, StringComparison.Ordinal)) + { + return false; + } + + if (rule.Status is { Length: > 0 } status) + { + if (failure.Exception is not ApiCallFailureException api || !StatusMatches(status, api.StatusCode)) + { + return false; + } + } + + return true; + } + + /// Matches an exact code, or a class such as 5xx. + internal static bool StatusMatches(string pattern, int status) + { + if (int.TryParse(pattern, out int exact)) + { + return exact == status; + } + + return pattern.Length == 3 && + char.IsDigit(pattern[0]) && + status / 100 == pattern[0] - '0'; + } + + // ---- notifications and triggers --------------------------------------------------------------- + + private static NotificationPolicy BuildNotificationPolicy(DslDocument document) + { + if (document.Notifications is not { } declared) + { + return NotificationPolicy.Default; + } + + return new NotificationPolicy + { + Level = LevelOf(declared.Level), + StreamEvents = declared.Stream, + ByNode = declared.ByNode.ToDictionary(p => p.Key, p => LevelOf(p.Value), StringComparer.Ordinal), + // Names declared up front plus every per-node 'notify', so the catalog advertises + // everything this workflow can emit rather than only what was listed twice. + Emits = declared.Emits + .Concat(document.Nodes.Where(n => n.Notify is not null).Select(n => n.Notify!.Name)) + .Distinct(StringComparer.Ordinal) + .ToArray() + }; + } + + private static NotificationLevel LevelOf(string level) => level switch + { + "minimal" => NotificationLevel.Minimal, + "lifecycle" => NotificationLevel.Lifecycle, + _ => NotificationLevel.Standard + }; + + private static IReadOnlyList BuildTriggers(DslDocument document) + => [.. document.Triggers.Select(t => new DomainEventTrigger + { + TopicFilter = t.Topic, + + // A literal filter value, not a projection: the subscription is registered before any + // message exists. The validator warns about one written to look like an expression. + CorrelationKey = t.CorrelationKey is { Length: > 0 } key ? key : null, + + ContextSelector = t.ContextFrom is { Length: > 0 } projection + ? message => ProjectContext(projection, message) + : null + })]; + + /// + /// Maps a triggering message onto the workflow's start context. The payload is bound to + /// $; there is no $ctx yet, because this is what produces it. + /// + private static string ProjectContext(string expression, DomainEventMessage message) + { + JsonNode? payload = null; + try + { + payload = JsonNode.Parse(message.PayloadJson); + } + catch (JsonException) + { + // A payload that is not JSON cannot be projected. Falling through to an empty context + // starts the instance and lets its own context schema report the real problem. + } + + AbExValue value = DslExpressions.Evaluate(expression, new AbExContext(payload, payload, [])); + + return value.IsAbsent + ? message.PayloadJson + : value.ToNode()?.ToJsonString() ?? message.PayloadJson; + } +} + +/// A DSL workflow that keeps an audit record, because its document declared one. +public sealed class DslAuditedWorkflowDefinition : DslWorkflowDefinition, IAuditedWorkflowDefinition +{ + internal DslAuditedWorkflowDefinition( + DslDocument document, DslNodeCatalog catalog, TimeProvider? clock = null) + : base(document, catalog, clock) + { + DslAudit audit = document.Audit + ?? throw new InvalidOperationException( + $"'{document.Name}' was built as audited but declares no audit block."); + + AuditRecord = new AuditRecordDefinition( + document.Name, + document.Description ?? $"Audit record for '{document.Name}'.", + [.. audit.Sections.Select(s => new AuditSectionDefinition(s, $"'{s}' entries."))]); + } + + public AuditRecordDefinition AuditRecord { get; } +} diff --git a/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs b/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs new file mode 100644 index 0000000..358b691 --- /dev/null +++ b/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Dsl.Model; + +namespace Abacus.Run.Dsl.Interpretation; + +/// What a factory is given when a document asks for one of its nodes. +public sealed record DslNodeContext( + DslNode Node, + DslDocument Document, + WorkflowBuildContext Build, + bool IsOutput = false) +{ + /// The host's services. Same provider a compiled definition resolves from. + public IServiceProvider? Services => Build.Services; + + /// The with block, for a custom node. Empty rather than null so callers need no guard. + public JsonObject Parameters => Node is DslCustomNode { With: JsonObject with } ? with : []; + + public T Require() where T : notnull + => Services is null + ? throw new InvalidOperationException( + $"Node '{Node.Id}' needs {typeof(T).Name}, but the build has no service provider.") + : (T)(Services.GetService(typeof(T)) + ?? throw new InvalidOperationException( + $"Node '{Node.Id}' needs {typeof(T).Name}, which is not registered. " + + $"A '{Node.Kind}' node cannot run without it.")); + + public T? Optional() where T : class => Services?.GetService(typeof(T)) as T; +} + +/// +/// Turns one declared node into an executor. +/// +/// +/// +/// This is the whole extensibility story. The DSL composes registered behaviour and never carries +/// behaviour of its own, so the answer to "the DSL cannot express this" is always ship a node +/// and name it — never embed a script. Engineers extend the vocabulary; authors +/// compose it. +/// +/// +/// The executor returned must be a , or at least a +/// HostExecutor<DslMessage, DslMessage>: every edge in a DSL graph carries the envelope, +/// and a node that emitted anything else would break the next edge rather than its own. +/// +/// +public interface IDslNodeFactory +{ + /// The name a document uses in "node". Lower-kebab, like a node id. + string Name { get; } + + /// + /// JSON Schema for this node's with block, validated at registration. Null accepts + /// anything — reasonable for a node with no parameters, and a missed opportunity otherwise, + /// since a schema here turns a run-time surprise into a startup failure. + /// + JsonNode? ParameterSchema => null; + + IHostExecutor Create(DslNodeContext context); +} + +/// Adapts a delegate into a factory, for a node whose construction is a one-liner. +public sealed class DelegateDslNodeFactory : IDslNodeFactory +{ + private readonly Func _create; + + public DelegateDslNodeFactory( + string name, Func create, JsonNode? parameterSchema = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + _create = create ?? throw new ArgumentNullException(nameof(create)); + ParameterSchema = parameterSchema; + } + + public string Name { get; } + + public JsonNode? ParameterSchema { get; } + + public IHostExecutor Create(DslNodeContext context) => _create(context); +} + +/// The registered custom node catalog, resolved at registration rather than at run time. +public sealed class DslNodeCatalog +{ + private readonly Dictionary _factories = new(StringComparer.Ordinal); + + public IReadOnlyCollection Names => _factories.Keys; + + public DslNodeCatalog Add(IDslNodeFactory factory) + { + ArgumentNullException.ThrowIfNull(factory); + + if (!_factories.TryAdd(factory.Name, factory)) + { + // Two factories under one name is a composition mistake, and the one that would win is + // whichever registration ran first — not something to discover from behaviour. + throw new InvalidOperationException( + $"A DSL node named '{factory.Name}' is already registered."); + } + + return this; + } + + public bool TryGet(string name, out IDslNodeFactory factory) => _factories.TryGetValue(name, out factory!); + + /// The shape the semantic validator checks with blocks against. + public IReadOnlyDictionary Describe() + => _factories.ToDictionary(p => p.Key, p => p.Value.ParameterSchema, StringComparer.Ordinal); +} diff --git a/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs b/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs index c1eeb5b..ce75ce0 100644 --- a/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs +++ b/src/Abacus.Run.Dsl/Validation/DslSemanticValidator.cs @@ -374,11 +374,20 @@ private static void CheckExpressions( foreach (DslTrigger trigger in document.Triggers) { - if (trigger.CorrelationKey is { Length: > 0 } key) + // A trigger's correlation key is a literal filter value, not a projection: the + // subscription is registered before any message exists, so there is nothing for a path + // to read. One that looks like an expression is almost certainly a misunderstanding. + if (trigger.CorrelationKey is { Length: > 0 } key && key.StartsWith('$')) { - Check($"{trigger.Pointer}/correlationKey", key, false, "correlation key"); + diagnostics.Add(DslDiagnostic.Warning( + DslCodes.ExpressionParseError, $"{trigger.Pointer}/correlationKey", + $"'{key}' is used as a literal correlation key, not evaluated.", + "A trigger subscription is registered before any message arrives, so there is " + + "nothing for an expression to read. Use the literal key you expect to match.")); } + // contextFrom does have a message in scope — the one that fired the trigger — so it is + // a real expression, rooted at the payload. if (trigger.ContextFrom is { Length: > 0 } from) { Check($"{trigger.Pointer}/contextFrom", from, false, "context projection"); diff --git a/src/Abacus.Run.Service/Abacus.Run.Service.csproj b/src/Abacus.Run.Service/Abacus.Run.Service.csproj index 0f8cd8c..497615e 100644 --- a/src/Abacus.Run.Service/Abacus.Run.Service.csproj +++ b/src/Abacus.Run.Service/Abacus.Run.Service.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Abacus.Run.Service/Program.cs b/src/Abacus.Run.Service/Program.cs index 5a4d5a1..c9a1e96 100644 --- a/src/Abacus.Run.Service/Program.cs +++ b/src/Abacus.Run.Service/Program.cs @@ -1,4 +1,5 @@ using Abacus.Run.Api; +using Abacus.Run.Dsl.Hosting; using Abacus.Run.Service.ControlPlane; using Abacus.Run.Service.Infrastructure.Auditing; using Abacus.Run.Core; @@ -16,7 +17,12 @@ .AddAbacus(builder.Configuration) // The worked example of the audit hook. It is the only workflow this host ships; a real // deployment registers its own definitions here the same way. - .AddWorkflow(); + .AddWorkflow() + + // Makes the DSL routes usable before any document exists — which is the state a host is in while + // someone is writing their first one. Documents are added with AddDslWorkflow(path) or + // AddDslWorkflowsFromDirectory(...) alongside the compiled registrations above. + .UseDsl(); // Durable, workflow-agnostic storage for the audit records that workflow definitions declare. // Displaces the framework's in-memory default. @@ -31,6 +37,7 @@ app.UseRouting(); app.MapWorkflowApi(); +app.MapDslApi(); app.MapControlPlane("/control"); app.MapGet("/health/live", () => Results.Ok(new { status = "live" })); app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" })); diff --git a/src/Abacus.Run.Service/Properties/launchSettings.json b/src/Abacus.Run.Service/Properties/launchSettings.json new file mode 100644 index 0000000..b408729 --- /dev/null +++ b/src/Abacus.Run.Service/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Abacus.Run.Service": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59211;http://localhost:59212" + } + } +} \ No newline at end of file diff --git a/src/Abacus.Run/Core/WorkflowRegistry.cs b/src/Abacus.Run/Core/WorkflowRegistry.cs index 2fffdfd..392c2df 100644 --- a/src/Abacus.Run/Core/WorkflowRegistry.cs +++ b/src/Abacus.Run/Core/WorkflowRegistry.cs @@ -20,6 +20,26 @@ public static ContextValidationResult Invalid(string field, string error) => new(false, new Dictionary { [field] = [error] }); } +/// +/// Implemented by a workflow definition that validates its own start payload beyond binding it to +/// . +/// +/// +/// +/// The registry's default check is a type bind, which is the whole story for a definition whose +/// context is a C# record. It is no story at all for one whose context is a JSON schema declared in +/// a document, so such a definition answers for itself. +/// +/// +/// Opt-in and additive: a definition that does not implement this behaves exactly as before, and the +/// hook runs only after the type bind has already succeeded. +/// +/// +public interface IContextValidatingWorkflow +{ + ContextValidationResult ValidateContext(JsonElement context); +} + public interface IWorkflowRegistry { IReadOnlyList All { get; } @@ -102,8 +122,16 @@ public ContextValidationResult ValidateContext(WorkflowDescriptor descriptor, Js try { object? deserialized = context.Value.Deserialize(descriptor.ContextType, JsonOptions.Default); - return deserialized is null - ? ContextValidationResult.Invalid("context", $"Could not bind context to '{descriptor.ContextType.Name}'.") + if (deserialized is null) + { + return ContextValidationResult.Invalid( + "context", $"Could not bind context to '{descriptor.ContextType.Name}'."); + } + + // Runs only after the bind succeeded, so a definition's own check never has to repeat + // what the type system already established. + return descriptor.Definition is IContextValidatingWorkflow validating + ? validating.ValidateContext(context.Value) : ContextValidationResult.Valid; } catch (JsonException ex) diff --git a/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs b/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs index bab349c..ecd896d 100644 --- a/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs +++ b/tests/Abacus.Run.DslTests/AbExEvaluatorTests.cs @@ -343,4 +343,65 @@ public void ToText_renders_for_templates(string expression, string expected) [Fact] public void Trailing_zeros_are_trimmed() => AbExValue.Number(1.50m).ToText().Should().Be("1.5"); + + // ---- value classification ------------------------------------------------------------- + + /// + /// A JsonValue holds whatever the writer put in it. Probing CLR types in turn used to fail for + /// an int-backed value — JsonValue.Create(200) will not hand back a decimal — and fall through + /// to the string branch, so an HTTP status of 200 compared as "200" and never equalled 200. + /// + [Fact] + public void Numbers_are_read_whatever_clr_type_backs_them() + { + var data = new JsonObject + { + ["fromInt"] = JsonValue.Create(200), + ["fromLong"] = JsonValue.Create(11L), + ["fromDouble"] = JsonValue.Create(1.5d), + ["fromDecimal"] = JsonValue.Create(429.5m), + ["fromFloat"] = JsonValue.Create(2.5f) + }; + + var context = new AbExContext(data, null, []); + + foreach (string field in new[] { "fromInt", "fromLong", "fromDouble", "fromDecimal", "fromFloat" }) + { + Eval($"$.{field}", context).Kind.Should().Be(AbExValueKind.Number, $"{field} is a number"); + } + + Eval("$.fromInt", context).AsNumber.Should().Be(200m); + Eval("$.fromLong", context).AsNumber.Should().Be(11m); + Eval("$.fromDecimal", context).AsNumber.Should().Be(429.5m); + Cond("$.fromInt == 200", context).Should().BeTrue(); + Cond("$.fromInt > 199", context).Should().BeTrue(); + } + + [Fact] + public void Booleans_and_strings_are_read_whatever_backs_them() + { + var data = new JsonObject + { + ["flag"] = JsonValue.Create(true), + ["name"] = JsonValue.Create("abc") + }; + + var context = new AbExContext(data, null, []); + + Eval("$.flag", context).Kind.Should().Be(AbExValueKind.Boolean); + Cond("$.flag", context).Should().BeTrue(); + Eval("$.name", context).Kind.Should().Be(AbExValueKind.String); + Eval("$.name", context).AsString.Should().Be("abc"); + } + + /// Values written by a node must read back the same way after a JSON round trip. + [Fact] + public void Classification_survives_a_serialization_round_trip() + { + var original = new JsonObject { ["status"] = JsonValue.Create(200) }; + JsonNode? reparsed = JsonNode.Parse(original.ToJsonString()); + + Eval("$.status", new AbExContext(original, null, [])).AsNumber.Should().Be(200m); + Eval("$.status", new AbExContext(reparsed, null, [])).AsNumber.Should().Be(200m); + } } diff --git a/tests/Abacus.Run.DslTests/DslInterpreterTests.cs b/tests/Abacus.Run.DslTests/DslInterpreterTests.cs new file mode 100644 index 0000000..b3fdcc6 --- /dev/null +++ b/tests/Abacus.Run.DslTests/DslInterpreterTests.cs @@ -0,0 +1,446 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Expressions; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Dsl.Model; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.DslTests; + +public class DslExpressionApplyTests +{ + private static AbExContext Context(string data, string? ctx = null) + => new(JsonNode.Parse(data), ctx is null ? null : JsonNode.Parse(ctx), []); + + [Fact] + public void Set_merges_into_existing_data() + { + JsonNode? result = DslExpressions.ApplySet( + new Dictionary { ["b"] = "2" }, + Context("""{ "a": 1 }"""), + JsonNode.Parse("""{ "a": 1 }"""), + replace: false); + + result!.ToJsonString().Should().Contain("\"a\":1").And.Contain("\"b\":2"); + } + + [Fact] + public void Replace_discards_existing_data() + { + JsonNode? result = DslExpressions.ApplySet( + new Dictionary { ["b"] = "2" }, + Context("""{ "a": 1 }"""), + JsonNode.Parse("""{ "a": 1 }"""), + replace: true); + + result!.ToJsonString().Should().NotContain("\"a\"").And.Contain("\"b\":2"); + } + + [Fact] + public void Set_writes_dotted_paths() + { + JsonNode? result = DslExpressions.ApplySet( + new Dictionary { ["order.total"] = "10 * 2" }, + Context("{}"), JsonNode.Parse("{}"), replace: false); + + result!["order"]!["total"]!.GetValue().Should().Be(20); + } + + /// + /// Every expression reads the value as it was before the transform, so the order the properties + /// happen to be written in cannot change the result. + /// + [Fact] + public void Set_expressions_read_the_pre_transform_value() + { + JsonNode? result = DslExpressions.ApplySet( + new Dictionary { ["a"] = "$.a + 1", ["b"] = "$.a + 10" }, + Context("""{ "a": 1 }"""), + JsonNode.Parse("""{ "a": 1 }"""), + replace: false); + + result!["a"]!.GetValue().Should().Be(2); + result["b"]!.GetValue().Should().Be(11, "b reads a as it was, not as a just became"); + } + + [Fact] + public void Absent_expressions_write_null() + { + JsonNode? result = DslExpressions.ApplySet( + new Dictionary { ["x"] = "$.missing" }, + Context("{}"), JsonNode.Parse("{}"), replace: false); + + result!["x"].Should().BeNull(); + } + + [Fact] + public void Assign_replaces_a_non_object_segment() + { + var root = new JsonObject { ["a"] = 1 }; + DslExpressions.Assign(root, "a.b", JsonValue.Create(2)); + + root["a"]!["b"]!.GetValue().Should().Be(2); + } + + [Fact] + public void Trees_are_cached_by_text() + => DslExpressions.Tree("$.a + 1").Should().BeSameAs(DslExpressions.Tree("$.a + 1")); + + [Fact] + public void An_unparseable_expression_yields_a_null_tree_rather_than_throwing() + { + Action act = () => DslExpressions.Tree("$.a = 1"); + act.Should().NotThrow(); + DslExpressions.Tree("$.a = 1").Should().BeNull(); + DslExpressions.Evaluate("$.a = 1", AbExContext.Empty).IsAbsent.Should().BeTrue(); + } +} + +public class DslWorkflowDefinitionTests +{ + private static DslWorkflowDefinition Build(string text) + => DslWorkflowDefinition.Create(DslParser.ParseOrThrow(text)); + + [Fact] + public void Exposes_name_version_and_hash() + { + DslWorkflowDefinition definition = Build(DslFixtures.FullText); + + definition.Name.Should().Be("order-settlement"); + definition.Version.Should().Be("1.2.0"); + definition.DocumentHash.Should().MatchRegex("^[0-9a-f]{64}$"); + ((IWorkflowDefinition)definition).ContextType.Should().Be(); + } + + // ---- context validation -------------------------------------------------------------------- + + [Fact] + public void Accepts_a_context_matching_the_declared_schema() + { + using JsonDocument document = JsonDocument.Parse("""{ "orderId": "ORD-1", "amount": 10 }"""); + + Build(DslFixtures.FullText).ValidateContext(document.RootElement).IsValid.Should().BeTrue(); + } + + [Fact] + public void Rejects_a_context_missing_a_required_property() + { + using JsonDocument document = JsonDocument.Parse("""{ "amount": 10 }"""); + + ContextValidationResult result = Build(DslFixtures.FullText).ValidateContext(document.RootElement); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + [Fact] + public void Rejects_a_context_with_a_wrong_property_type() + { + using JsonDocument document = JsonDocument.Parse("""{ "orderId": 7 }"""); + + ContextValidationResult result = Build(DslFixtures.FullText).ValidateContext(document.RootElement); + + result.IsValid.Should().BeFalse(); + result.Errors.Keys.Should().Contain(k => k.Contains("orderId", StringComparison.Ordinal)); + } + + [Fact] + public void A_document_with_no_context_schema_accepts_anything() + { + using JsonDocument document = JsonDocument.Parse("""{ "whatever": true }"""); + + Build(DslFixtures.MinimalText).ValidateContext(document.RootElement).IsValid.Should().BeTrue(); + } + + // ---- notifications -------------------------------------------------------------------------- + + [Fact] + public void A_document_with_no_notifications_block_gets_the_default_policy() + { + NotificationPolicy policy = Build(DslFixtures.MinimalText).Notifications; + + policy.Level.Should().Be(NotificationLevel.Standard); + policy.StreamEvents.Should().BeTrue(); + } + + [Theory] + [InlineData("minimal", NotificationLevel.Minimal)] + [InlineData("lifecycle", NotificationLevel.Lifecycle)] + [InlineData("standard", NotificationLevel.Standard)] + public void Notification_level_is_mapped(string declared, NotificationLevel expected) + { + string text = DslFixtures.Broken(d => d["notifications"] = new JsonObject { ["level"] = declared }); + Build(text).Notifications.Level.Should().Be(expected); + } + + [Fact] + public void Stream_false_becomes_log_only() + { + string text = DslFixtures.Broken(d => d["notifications"] = new JsonObject { ["stream"] = false }); + + NotificationPolicy policy = Build(text).Notifications; + policy.StreamEvents.Should().BeFalse(); + policy.IsLogOnly.Should().BeTrue(); + } + + [Fact] + public void Per_node_overrides_are_mapped() + { + string text = DslFixtures.Broken(d => d["notifications"] = new JsonObject + { + ["level"] = "minimal", + ["byNode"] = new JsonObject { ["b"] = "standard" } + }); + + Build(text).Notifications.ByNode["b"].Should().Be(NotificationLevel.Standard); + } + + /// + /// The catalog should advertise everything the workflow can emit, not only what an author + /// remembered to list in two places. + /// + [Fact] + public void Per_node_notify_names_join_the_declared_emits() + { + string text = DslFixtures.Broken(d => + { + d["notifications"] = new JsonObject { ["emits"] = new JsonArray("declared") }; + DslFixtures.Node(d, 0)["notify"] = new JsonObject { ["name"] = "priced" }; + }); + + Build(text).Notifications.Emits.Should().BeEquivalentTo("declared", "priced"); + } + + // ---- triggers ------------------------------------------------------------------------------- + + [Fact] + public void A_document_with_no_triggers_declares_none() + => Build(DslFixtures.MinimalText).Triggers.Should().BeEmpty(); + + [Fact] + public void Trigger_topics_are_mapped() + { + IReadOnlyList triggers = Build(DslFixtures.FullText).Triggers; + + triggers.Should().ContainSingle(); + triggers[0].TopicFilter.Should().Be("orders.placed"); + } + + [Fact] + public void A_literal_correlation_key_on_a_trigger_is_kept() + { + string text = DslFixtures.Broken(d => d["triggers"] = new JsonArray( + new JsonObject { ["topic"] = "orders.placed", ["correlationKey"] = "fixed-key" })); + + Build(text).Triggers[0].CorrelationKey.Should().Be("fixed-key"); + } + + /// + /// A trigger subscription is registered before any message exists, so a correlation key written + /// as an expression is a misunderstanding rather than a projection. Warned about, not silently + /// evaluated or silently dropped. + /// + [Fact] + public void An_expression_shaped_correlation_key_is_warned_about() + { + DslParseResult result = DslParser.Parse(DslFixtures.FullText); + + result.IsValid.Should().BeTrue("a warning must not stop the document registering"); + result.Validation.Warnings.Should().Contain(d => d.Pointer == "/triggers/0/correlationKey"); + } + + [Fact] + public void ContextFrom_projects_the_triggering_message() + { + string text = DslFixtures.Broken(d => d["triggers"] = new JsonArray( + new JsonObject { ["topic"] = "orders.placed", ["contextFrom"] = "$.order" })); + + DomainEventTrigger trigger = Build(text).Triggers[0]; + trigger.ContextSelector.Should().NotBeNull(); + + string projected = trigger.ContextSelector!(new DomainEventMessage + { + MessageId = "m-1", + Topic = "orders.placed", + PayloadJson = """{ "order": { "id": "ORD-3" }, "noise": 1 }""", + OccurredAt = DateTimeOffset.UtcNow + }); + + projected.Should().Contain("ORD-3").And.NotContain("noise"); + } + + [Fact] + public void ContextFrom_falls_back_to_the_whole_payload_when_it_resolves_to_nothing() + { + string text = DslFixtures.Broken(d => d["triggers"] = new JsonArray( + new JsonObject { ["topic"] = "orders.placed", ["contextFrom"] = "$.missing" })); + + string projected = Build(text).Triggers[0].ContextSelector!(new DomainEventMessage + { + MessageId = "m-1", + Topic = "orders.placed", + PayloadJson = """{ "order": 1 }""", + OccurredAt = DateTimeOffset.UtcNow + }); + + projected.Should().Contain("order"); + } + + // ---- audit ------------------------------------------------------------------------------------ + + [Fact] + public void A_document_with_no_audit_block_keeps_no_record() + => Build(DslFixtures.MinimalText).Should().NotBeAssignableTo(); + + [Fact] + public void A_document_with_an_audit_block_declares_a_record() + { + var audited = Build(DslFixtures.FullText) as IAuditedWorkflowDefinition; + + audited.Should().NotBeNull(); + audited!.AuditRecord.RootKind.Should().Be("order-settlement"); + audited.AuditRecord.Allows("submission").Should().BeTrue(); + audited.AuditRecord.Allows("outcome").Should().BeTrue(); + audited.AuditRecord.Allows("never-declared").Should().BeFalse(); + } + + // ---- failure classification --------------------------------------------------------------------- + + private static WorkflowFailure Failure(Exception exception, string executorId = "settle") + => WorkflowFailure.Create(executorId, exception); + + [Fact] + public void A_matching_rule_decides() + { + DslWorkflowDefinition definition = Build(DslFixtures.FullText); + + definition.Classify(Failure(new ApiCallFailureException(503, "unavailable", null))) + .Should().Be(FailureDisposition.Retry); + } + + [Fact] + public void A_status_class_matches_the_whole_range() + { + DslWorkflowDefinition.StatusMatches("5xx", 500).Should().BeTrue(); + DslWorkflowDefinition.StatusMatches("5xx", 599).Should().BeTrue(); + DslWorkflowDefinition.StatusMatches("5xx", 499).Should().BeFalse(); + DslWorkflowDefinition.StatusMatches("404", 404).Should().BeTrue(); + DslWorkflowDefinition.StatusMatches("404", 400).Should().BeFalse(); + } + + [Fact] + public void A_rule_scoped_to_a_node_does_not_match_another() + { + string text = DslFixtures.Broken(d => d["onFailure"] = new JsonArray( + new JsonObject + { + ["match"] = new JsonObject { ["node"] = "a" }, + ["disposition"] = "escalate" + })); + + DslWorkflowDefinition definition = Build(text); + + definition.Classify(Failure(new InvalidOperationException(), "a")) + .Should().Be(FailureDisposition.Escalate); + definition.Classify(Failure(new InvalidOperationException(), "b")) + .Should().NotBe(FailureDisposition.Escalate); + } + + /// + /// A document should only have to state where it disagrees; the framework already knows a rate + /// limit is worth retrying and a validation error is not. + /// + [Fact] + public void An_unmatched_failure_defers_to_the_framework_classifier() + { + DslWorkflowDefinition definition = Build(DslFixtures.MinimalText); + var failure = Failure(new WorkflowDeadStopException("no")); + + definition.Classify(failure) + .Should().Be(DefaultFailureClassifier.Instance.Classify(failure)); + } + + [Fact] + public void Rules_are_applied_in_declaration_order() + { + string text = DslFixtures.Broken(d => d["onFailure"] = new JsonArray( + new JsonObject + { + ["match"] = new JsonObject { ["exception"] = "ApiCallFailureException" }, + ["disposition"] = "deadStop" + }, + new JsonObject + { + ["match"] = new JsonObject { ["exception"] = "ApiCallFailureException" }, + ["disposition"] = "retry" + })); + + Build(text).Classify(Failure(new ApiCallFailureException(500, "x", null))) + .Should().Be(FailureDisposition.DeadStop); + } +} + +public class DslNodeCatalogTests +{ + private sealed class StubFactory(string name, JsonNode? schema = null) : IDslNodeFactory + { + public string Name => name; + public JsonNode? ParameterSchema => schema; + public IHostExecutor Create(DslNodeContext context) => throw new NotSupportedException(); + } + + [Fact] + public void Registers_and_resolves_by_name() + { + var catalog = new DslNodeCatalog().Add(new StubFactory("score-risk")); + + catalog.TryGet("score-risk", out IDslNodeFactory factory).Should().BeTrue(); + factory.Name.Should().Be("score-risk"); + catalog.Names.Should().Equal("score-risk"); + } + + [Fact] + public void An_unknown_name_does_not_resolve() + => new DslNodeCatalog().TryGet("nope", out _).Should().BeFalse(); + + /// + /// Which one wins would be whichever registration ran first — not something to discover from + /// behaviour. + /// + [Fact] + public void A_duplicate_name_is_refused() + { + var catalog = new DslNodeCatalog().Add(new StubFactory("score-risk")); + + Action act = () => catalog.Add(new StubFactory("score-risk")); + act.Should().Throw().WithMessage("*already registered*"); + } + + [Fact] + public void Describe_exposes_parameter_schemas_for_validation() + { + JsonNode schema = JsonNode.Parse("""{ "type": "object" }""")!; + var catalog = new DslNodeCatalog().Add(new StubFactory("with-schema", schema)) + .Add(new StubFactory("without-schema")); + + IReadOnlyDictionary described = catalog.Describe(); + + described.Should().HaveCount(2); + described["with-schema"].Should().NotBeNull(); + described["without-schema"].Should().BeNull(); + } + + [Fact] + public void A_delegate_factory_carries_its_name_and_schema() + { + JsonNode schema = JsonNode.Parse("""{ "type": "object" }""")!; + var factory = new DelegateDslNodeFactory("quick", _ => throw new NotSupportedException(), schema); + + factory.Name.Should().Be("quick"); + factory.ParameterSchema.Should().BeSameAs(schema); + } +} diff --git a/tests/Abacus.Run.IntegrationTests/Abacus.Run.IntegrationTests.csproj b/tests/Abacus.Run.IntegrationTests/Abacus.Run.IntegrationTests.csproj index 2cbd4d7..757cbd2 100644 --- a/tests/Abacus.Run.IntegrationTests/Abacus.Run.IntegrationTests.csproj +++ b/tests/Abacus.Run.IntegrationTests/Abacus.Run.IntegrationTests.csproj @@ -3,6 +3,7 @@ false + diff --git a/tests/Abacus.Run.IntegrationTests/DslDocuments.cs b/tests/Abacus.Run.IntegrationTests/DslDocuments.cs new file mode 100644 index 0000000..ac12124 --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslDocuments.cs @@ -0,0 +1,372 @@ +namespace Abacus.Run.IntegrationTests; + +/// +/// One document per conversion the interpreter performs. Together they exercise every node kind, +/// every edge shape, gates, notifications, triggers, audit and failure rules — so a regression in any +/// one mapping fails a named test rather than a general "the DSL broke". +/// +internal static class DslDocuments +{ + /// Linear: two transforms and one edge. The envelope's baseline. + internal const string Linear = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-linear", + "version": "1.0.0", + "context": { + "type": "object", + "required": ["orderId"], + "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } } + }, + "start": "price", + "output": ["finish"], + "nodes": [ + { "id": "price", "kind": "transform", + "set": { "total": "$ctx.amount * 2", "order": "$ctx.orderId" } }, + { "id": "finish", "kind": "transform", + "set": { "status": "'done'", "carried": "$ctx.orderId", "total": "$.total" } } + ], + "edges": [ { "from": "price", "to": "finish" } ] + } + """; + + /// Branch: two conditional edges out of one node, which is how the DSL says "if". + internal const string Branch = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-branch", + "version": "1.0.0", + "start": "classify", + "output": ["large", "small"], + "nodes": [ + { "id": "classify", "kind": "transform", "set": { "amount": "$ctx.amount" } }, + { "id": "large", "kind": "transform", "set": { "band": "'large'" } }, + { "id": "small", "kind": "transform", "set": { "band": "'small'" } } + ], + "edges": [ + { "from": "classify", "to": "large", "when": "$.amount > 1000" }, + { "from": "classify", "to": "small", "when": "$.amount <= 1000" } + ] + } + """; + + /// Fan-out to two branches, then a barrier that waits for both. + internal const string FanOutIn = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-fan", + "version": "1.0.0", + "start": "split", + "output": ["join"], + "nodes": [ + { "id": "split", "kind": "transform", "set": { "seed": "$ctx.amount" } }, + { "id": "left", "kind": "transform", "set": { "side": "'left'", "value": "$.seed + 1" } }, + { "id": "right", "kind": "transform", "set": { "side": "'right'", "value": "$.seed + 2" } }, + { "id": "join", "kind": "fan-in", "into": "branches" } + ], + "edges": [ + { "from": "split", "to": ["left", "right"] }, + { "from": ["left", "right"], "to": "join" } + ] + } + """; + + /// A conditional gate that trips above a threshold, parking the instance. + internal const string Gated = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-gated", + "version": "1.0.0", + "start": "prepare", + "output": ["settle"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "amount": "$ctx.amount" } }, + { "id": "settle", "kind": "transform", + "set": { "status": "'settled'" }, + "gate": { + "mode": "conditional", + "when": "$.amount > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance"], + "expiresAfter": "PT8H", + "onExpiry": { "action": "deadStop" } + } } + ], + "edges": [ { "from": "prepare", "to": "settle" } ] + } + """; + + + /// + /// The same gate with no assignees. An assigned gate correctly refuses an anonymous decider with + /// 403, so the resume path needs a gate anyone may decide. + /// + internal const string GatedOpen = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-gated-open", + "version": "1.0.0", + "start": "prepare", + "output": ["settle"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "amount": "$ctx.amount" } }, + { "id": "settle", "kind": "transform", + "set": { "status": "'settled'" }, + "gate": { "mode": "conditional", "when": "$.amount > 25000", "reason": "LargeSettlement" } } + ], + "edges": [ { "from": "prepare", "to": "settle" } ] + } + """; + /// An explicit approval node: the gate is the node, not configuration elsewhere. + internal const string ApprovalNode = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-approval", + "version": "1.0.0", + "start": "prepare", + "output": ["done"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "amount": "$ctx.amount" } }, + { "id": "sign-off", "kind": "approval" }, + { "id": "done", "kind": "transform", "set": { "status": "'approved'" } } + ], + "edges": [ + { "from": "prepare", "to": "sign-off" }, + { "from": "sign-off", "to": "done" } + ] + } + """; + + /// An HTTP call, with the response projected onto the envelope as status and body. + internal const string Http = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-http", + "version": "1.0.0", + "start": "call", + "output": ["read"], + "nodes": [ + { "id": "call", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/orders/{{ $ctx.orderId }}", + "headers": { "X-Order": "{{ $ctx.orderId }}" }, + "body": "{\"amount\":{{ $ctx.amount }}}", + "allowedHosts": ["ledger.internal"] }, + { "id": "read", "kind": "transform", + "set": { "status": "$.status", "reference": "$.body.reference" } } + ], + "edges": [ { "from": "call", "to": "read" } ] + } + """; + + /// An LLM node, with tokens and cost projected onto the envelope. + internal const string Llm = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-llm", + "version": "1.0.0", + "start": "summarise", + "output": ["read"], + "nodes": [ + { "id": "summarise", "kind": "llm", + "model": "stub-model", + "system": "You summarise orders.", + "prompt": "Summarise order {{ $ctx.orderId }} for {{ $ctx.amount }}." }, + { "id": "read", "kind": "transform", + "set": { "summary": "$.text", "inputTokens": "$.inputTokens", "model": "$.model" } } + ], + "edges": [ { "from": "summarise", "to": "read" } ] + } + """; + + /// A durable delay, which must pass the envelope through rather than replace it. + internal const string Delay = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-delay", + "version": "1.0.0", + "start": "prepare", + "output": ["after"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "marker": "'before'" } }, + { "id": "wait", "kind": "delay", "for": "PT1S" }, + { "id": "after", "kind": "transform", "set": { "carried": "$.marker" } } + ], + "edges": [ + { "from": "prepare", "to": "wait" }, + { "from": "wait", "to": "after" } + ] + } + """; + + /// Publishes a domain event on the way past, and carries on. + internal const string Publish = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-publish", + "version": "1.0.0", + "start": "prepare", + "output": ["done"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "orderId": "$ctx.orderId" } }, + { "id": "announce", "kind": "publish", + "topic": "dsl.orders.priced", + "payload": { "order": "$.orderId", "at": "'now'" }, + "correlationKey": "$.orderId" }, + { "id": "done", "kind": "transform", "set": { "status": "'published'" } } + ], + "edges": [ + { "from": "prepare", "to": "announce" }, + { "from": "announce", "to": "done" } + ] + } + """; + + /// Parks until a matching message arrives, then resumes with its payload. + internal const string WaitEvent = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-wait", + "version": "1.0.0", + "start": "prepare", + "output": ["settled"], + "nodes": [ + { "id": "prepare", "kind": "transform", "set": { "orderId": "$ctx.orderId" } }, + { "id": "await-payment", "kind": "wait-event", + "topic": "dsl.payment.settled", + "timeout": "P3D" }, + { "id": "settled", "kind": "transform", + "set": { "paidAmount": "$.amount", "order": "$ctx.orderId" } } + ], + "edges": [ + { "from": "prepare", "to": "await-payment" }, + { "from": "await-payment", "to": "settled" } + ] + } + """; + + /// A registered custom node — the DSL's extension seam. + internal const string Custom = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-custom", + "version": "1.0.0", + "start": "seed", + "output": ["double"], + "nodes": [ + { "id": "seed", "kind": "transform", "set": { "value": "$ctx.amount" } }, + { "id": "double", "kind": "custom", "node": "doubler", + "with": { "field": "value", "times": 3 } } + ], + "edges": [ { "from": "seed", "to": "double" } ] + } + """; + + /// A node that emits a workflow-defined notification. + internal const string Notifying = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-notify", + "version": "1.0.0", + "start": "price", + "output": ["price"], + "nodes": [ + { "id": "price", "kind": "transform", + "set": { "total": "$ctx.amount * 2" }, + "notify": { "name": "priced", "payload": { "total": "$.total", "order": "$ctx.orderId" } } } + ], + "edges": [], + "notifications": { "level": "standard", "stream": true } + } + """; + + /// Fails on an HTTP status the document classifies as dead-stop. + internal const string Failing = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-failing", + "version": "1.0.0", + "start": "call", + "output": ["call"], + "nodes": [ + { "id": "call", "kind": "http", + "url": "https://ledger.internal/v1/fail", + "allowedHosts": ["ledger.internal"] } + ], + "edges": [], + "onFailure": [ + { "match": { "exception": "ApiCallFailureException", "status": "4xx" }, "disposition": "deadStop" } + ] + } + """; + + /// Declares an audit record, so the audited variant is selected. + internal const string Audited = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-audited", + "version": "1.0.0", + "start": "only", + "output": ["only"], + "nodes": [ { "id": "only", "kind": "transform", "set": { "status": "'done'" } } ], + "edges": [], + "audit": { "key": "$ctx.orderId", "sections": ["submission", "outcome"] } + } + """; + + /// Started by a domain event rather than by API. + internal const string Triggered = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-triggered", + "version": "1.0.0", + "start": "handle", + "output": ["handle"], + "nodes": [ + { "id": "handle", "kind": "transform", "set": { "sawOrder": "$ctx.orderId" } } + ], + "edges": [], + "triggers": [ { "topic": "dsl.orders.placed" } ] + } + """; + + /// A fan-out whose selector picks a subset of targets by index. + internal const string SelectiveFanOut = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-select", + "version": "1.0.0", + "start": "route", + "output": ["first", "second"], + "nodes": [ + { "id": "route", "kind": "transform", "set": { "pick": "$ctx.amount" } }, + { "id": "first", "kind": "transform", "set": { "chosen": "'first'" } }, + { "id": "second", "kind": "transform", "set": { "chosen": "'second'" } } + ], + "edges": [ + { "from": "route", "to": ["first", "second"], "select": "$.pick" } + ] + } + """; + + internal static IReadOnlyList<(string Name, string Text)> All => + [ + ("linear", Linear), + ("branch", Branch), + ("fan-out-in", FanOutIn), + ("gated", Gated), + ("gated-open", GatedOpen), + ("approval", ApprovalNode), + ("http", Http), + ("llm", Llm), + ("delay", Delay), + ("publish", Publish), + ("wait-event", WaitEvent), + ("custom", Custom), + ("notifying", Notifying), + ("failing", Failing), + ("audited", Audited), + ("triggered", Triggered), + ("selective-fan-out", SelectiveFanOut) + ]; +} diff --git a/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs b/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs new file mode 100644 index 0000000..c5d6e68 --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs @@ -0,0 +1,287 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Api; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Hosting; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Executors; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Abacus.Run.IntegrationTests; + +/// Canned HTTP responses, so an http node can be exercised without a network. +public sealed class StubHttpHandler : HttpMessageHandler +{ + public ConcurrentQueue Requests { get; } = new(); + + public Func Respond { get; set; } = + _ => (HttpStatusCode.OK, """{ "ok": true }"""); + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Enqueue(request); + (HttpStatusCode status, string body) = Respond(request); + + return Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }); + } +} + +/// A chat client that answers with whatever the test told it to. +public sealed class StubChatClient : IChatClient +{ + public string Reply { get; set; } = "stub reply"; + + public List Prompts { get; } = []; + + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + Prompts.AddRange(messages.Select(m => m.Text ?? string.Empty)); + + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, Reply)) + { + ModelId = options?.ModelId ?? "stub", + Usage = new UsageDetails { InputTokenCount = 11, OutputTokenCount = 7 } + }); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatResponse response = await GetResponseAsync(messages, options, cancellationToken); + yield return new ChatResponseUpdate(ChatRole.Assistant, response.Text); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } +} + +/// In-memory timers, since the host ships no of its own. +public sealed class InMemoryTimerService : ITimerService +{ + private readonly ConcurrentBag<(string InstanceId, string ExecutorId, DateTimeOffset WakeAt)> _timers = []; + + public IReadOnlyCollection<(string InstanceId, string ExecutorId, DateTimeOffset WakeAt)> Scheduled => _timers; + + public ValueTask ScheduleAsync( + string instanceId, string executorId, DateTimeOffset wakeAt, CancellationToken cancellationToken) + { + _timers.Add((instanceId, executorId, wakeAt)); + return ValueTask.CompletedTask; + } + + public ValueTask> ClaimDueAsync( + DateTimeOffset now, int max, CancellationToken cancellationToken) + => ValueTask.FromResult>( + [.. _timers.Where(t => t.WakeAt <= now).Take(max).Select(t => (t.InstanceId, t.ExecutorId))]); +} + +/// +/// A custom node, registered by name — the seam that keeps the DSL from having a ceiling. It doubles +/// whatever number the document points it at, which is dull on purpose: the point under test is the +/// registration and parameter contract, not the arithmetic. +/// +public sealed class DoublerNodeFactory : IDslNodeFactory +{ + public string Name => "doubler"; + + public JsonNode? ParameterSchema => JsonNode.Parse(""" + { + "type": "object", + "required": ["field"], + "properties": { + "field": { "type": "string" }, + "times": { "type": "number" } + } + } + """); + + public IHostExecutor Create(DslNodeContext context) + { + string field = context.Parameters["field"]!.GetValue(); + decimal times = context.Parameters["times"]?.GetValue() ?? 2m; + + return new Doubler(context.Node.Id, field, times); + } + + private sealed class Doubler(string id, string field, decimal times) + : HostExecutor(id) + { + protected override ValueTask ExecuteCoreAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + var data = input.Data as JsonObject ?? []; + var updated = (JsonObject)data.DeepClone(); + + decimal current = updated[field]?.GetValue() ?? 0m; + updated[field] = current * times; + + return ValueTask.FromResult(input.WithData(updated)); + } + } +} + +/// A custom node that returns the wrong executor shape, to prove the check bites. +public sealed class WrongShapeNodeFactory : IDslNodeFactory +{ + public string Name => "wrong-shape"; + + public IHostExecutor Create(DslNodeContext context) + => new Wrong(context.Node.Id); + + private sealed class Wrong(string id) : HostExecutor(id) + { + protected override ValueTask ExecuteCoreAsync( + string input, IWorkflowContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(input); + } +} + +/// +/// A host with only DSL workflows registered, so a failure is unambiguously about the DSL rather +/// than about a compiled definition sitting beside it. +/// +public sealed class DslHostFixture : WebApplicationFactory +{ + public StubHttpHandler Http { get; } = new(); + public StubChatClient Chat { get; } = new(); + public InMemoryTimerService Timers { get; } = new(); + + private readonly string _auditDatabasePath = + Path.Combine(Path.GetTempPath(), $"abacus-dsl-audit-{Guid.NewGuid():N}.db"); + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.AddSingleton(Timers); + services.AddSingleton(Chat); + + // The DSL's http node resolves this named client, so a stub handler here reaches every + // http node without any of them knowing they are under test. + services.AddHttpClient(ApiCallOptions.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => Http); + + var host = new WorkflowHostBuilder(services); + + host.AddDslNode(new DoublerNodeFactory()) + .AddDslNode(new WrongShapeNodeFactory()) + .ConfigureDsl(registry => registry.EnforceEgress = false); + + foreach ((string name, string text) in DslDocuments.All) + { + host.AddDslWorkflowText(text, name); + } + }); + + return base.CreateHost(builder); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseSetting("WorkflowHost:Approvals:SweepIntervalSeconds", "1"); + builder.UseSetting("Abacus:AuditRecords:ConnectionString", $"Data Source={_auditDatabasePath}"); + builder.ConfigureServices(services => + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("http://localhost"); + }).ConfigurePrimaryHttpMessageHandler(() => Server.CreateHandler()); + }); + } + + public T Resolve() where T : notnull => Services.GetRequiredService(); + + public async Task WaitForStatusAsync(string instanceId, params InstanceStatus[] expected) + { + var store = Resolve(); + DateTime deadline = DateTime.UtcNow.AddSeconds(20); + + while (DateTime.UtcNow < deadline) + { + WorkflowInstance? instance = await store.GetAsync(instanceId, default); + if (instance is not null && expected.Contains(instance.Status)) + { + return instance; + } + + await Task.Delay(25); + } + + WorkflowInstance? last = await store.GetAsync(instanceId, default); + + // The status alone says a run stalled but never why, and the reason is almost always in the + // terminal reason or the instance log. Reading them here turns "expected Completed" into a + // message that names the actual fault. + IReadOnlyList logs = await Resolve() + .QueryAsync(instanceId, null, null, 50, default); + + string detail = logs.Count == 0 + ? "(no log entries)" + : string.Join(Environment.NewLine, logs.Select(l => $" [{l.Level}] {l.ExecutorId}: {l.Message}")); + + throw new TimeoutException( + $"Instance '{instanceId}' was '{last?.Status.ToString() ?? "missing"}', " + + $"expected one of {string.Join(", ", expected)}.{Environment.NewLine}" + + $"Terminal reason: {last?.TerminalReason ?? "(none)"}{Environment.NewLine}{detail}"); + } + + public async Task StartAsync(HttpClient client, string workflow, object context) + { + HttpResponseMessage response = await client.PostAsJsonAsync( + $"/workflows/{workflow}/instances", new { context }); + + response.EnsureSuccessStatusCode(); + + JsonElement body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("instanceId").GetString()!; + } + + /// Starts and waits, for the common "did this document run" assertion. + public async Task RunAsync( + HttpClient client, string workflow, object context, params InstanceStatus[] expected) + { + string id = await StartAsync(client, workflow, context); + return await WaitForStatusAsync(id, expected.Length > 0 + ? expected + : [InstanceStatus.Completed]); + } + + /// Reads the workflow result off a completed instance. + public async Task ResultOfAsync(string instanceId) + { + WorkflowInstance? instance = await Resolve().GetAsync(instanceId, default); + return instance?.ResultJson is { Length: > 0 } json ? JsonNode.Parse(json) : null; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) return; + + try + { + if (File.Exists(_auditDatabasePath)) File.Delete(_auditDatabasePath); + } + catch (IOException) + { + } + } +} diff --git a/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs new file mode 100644 index 0000000..daf4320 --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs @@ -0,0 +1,368 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Api; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Hosting; +using Abacus.Run.Dsl.Interpretation; +using Abacus.Run.Dsl.Validation; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Abacus.Run.IntegrationTests; + +/// Registration: what reaches the registry, and what fails startup instead. +public class DslRegistrationTests +{ + private static ServiceProvider Build(Action configure) + { + var services = new ServiceCollection(); + configure(new WorkflowHostBuilder(services)); + return services.BuildServiceProvider(); + } + + [Fact] + public void A_valid_document_registers_as_a_workflow_definition() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Linear, "linear")); + + IWorkflowDefinition[] definitions = [.. provider.GetServices()]; + + definitions.Should().ContainSingle(); + definitions[0].Name.Should().Be("dsl-linear"); + definitions[0].Should().BeAssignableTo(); + } + + /// + /// The order composition happens to be written in must not decide whether a document validates, + /// so resolution is deferred until every AddDslNode call has run. + /// + [Fact] + public void A_custom_node_registered_after_the_document_still_resolves() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Custom, "custom") + .AddDslNode(new DoublerNodeFactory())); + + Action act = () => provider.GetServices().ToArray(); + act.Should().NotThrow(); + } + + [Fact] + public void An_invalid_document_fails_startup_with_every_diagnostic() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(""" + { + "dsl": "abacus.workflow/1.0", + "name": "broken", "version": "1.0.0", "start": "nope", + "nodes": [ { "id": "a", "kind": "transform", "set": { "x": "1" } } ], + "edges": [ { "from": "a", "to": "ghost" } ] + } + """, "broken.json")); + + Action act = () => provider.GetServices().ToArray(); + + act.Should().Throw() + .Which.Message.Should().Contain("broken.json") + .And.Contain(DslCodes.StartNotFound) + .And.Contain(DslCodes.EdgeEndpointNotFound); + } + + [Fact] + public void An_unregistered_custom_node_fails_startup() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Custom, "custom")); + + Action act = () => provider.GetServices().ToArray(); + + act.Should().Throw() + .Which.Message.Should().Contain(DslCodes.UnknownCustomNode); + } + + /// A published version is immutable, and two documents claiming one must not both win. + [Fact] + public void Two_documents_claiming_one_version_fail_startup() + { + string second = DslDocuments.Linear.Replace("\"start\": \"price\"", "\"start\": \"finish\"", + StringComparison.Ordinal); + + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Linear, "first.json") + .AddDslWorkflowText(second, "second.json")); + + Action act = () => provider.GetServices().ToArray(); + + act.Should().Throw() + .Which.Message.Should().Contain(DslCodes.HashConflict); + } + + [Fact] + public void The_same_document_registered_twice_is_not_a_conflict() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Linear, "a.json") + .AddDslWorkflowText(DslDocuments.Linear, "b.json")); + + Action act = () => provider.GetServices().ToArray(); + act.Should().NotThrow("the documents are byte-identical, so nothing was redefined"); + } + + [Fact] + public void A_missing_directory_is_refused_at_composition_time() + { + var services = new ServiceCollection(); + var host = new WorkflowHostBuilder(services); + + Action act = () => host.AddDslWorkflowsFromDirectory( + Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}")); + + act.Should().Throw(); + } + + [Fact] + public void Documents_load_from_a_directory_in_a_stable_order() + { + string directory = Path.Combine(Path.GetTempPath(), $"dsl-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + + try + { + File.WriteAllText(Path.Combine(directory, "b.workflow.json"), DslDocuments.Linear); + File.WriteAllText(Path.Combine(directory, "a.workflow.json"), DslDocuments.Branch); + File.WriteAllText(Path.Combine(directory, "ignored.txt"), "not a document"); + + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowsFromDirectory(directory)); + + string[] names = [.. provider.GetServices().Select(d => d.Name)]; + + names.Should().Equal("dsl-branch", "dsl-linear"); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void A_document_from_disk_registers() + { + string path = Path.Combine(Path.GetTempPath(), $"dsl-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, DslDocuments.Linear); + + try + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflow(path)); + + provider.GetServices().Should().ContainSingle(); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Registering_a_duplicate_node_name_is_refused() + { + var services = new ServiceCollection(); + var host = new WorkflowHostBuilder(services); + + host.AddDslNode(new DoublerNodeFactory()); + + Action act = () => host.AddDslNode(new DoublerNodeFactory()); + act.Should().Throw().WithMessage("*already registered*"); + } +} + +/// The DSL's own control-plane routes. +public class DslEndpointTests : IClassFixture +{ + private readonly DslHostFixture _fixture; + + public DslEndpointTests(DslHostFixture fixture) => _fixture = fixture; + + [Fact] + public async Task The_schema_route_serves_the_published_schema() + { + using HttpClient client = _fixture.CreateClient(); + + HttpResponseMessage response = await client.GetAsync("/dsl/schema"); + response.EnsureSuccessStatusCode(); + + string body = await response.Content.ReadAsStringAsync(); + body.Should().Be(DslSchemaValidator.SchemaText); + body.Should().Contain("abacus.workflow/1.0"); + } + + [Fact] + public async Task The_node_route_lists_built_in_and_registered_nodes() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement nodes = await client.GetFromJsonAsync("/dsl/nodes"); + + string[] builtIn = [.. nodes.GetProperty("builtIn").EnumerateArray().Select(e => e.GetString()!)]; + builtIn.Should().Contain(["transform", "http", "llm", "delay", "approval", + "publish", "wait-event", "fan-in", "custom"]); + + string[] custom = [.. nodes.GetProperty("custom").EnumerateArray() + .Select(e => e.GetProperty("name").GetString()!)]; + custom.Should().Contain("doubler"); + } + + [Fact] + public async Task The_custom_node_listing_carries_its_parameter_schema() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement nodes = await client.GetFromJsonAsync("/dsl/nodes"); + + JsonElement doubler = nodes.GetProperty("custom").EnumerateArray() + .First(e => e.GetProperty("name").GetString() == "doubler"); + + doubler.GetProperty("parameterSchema").GetRawText().Should().Contain("field"); + } + + [Fact] + public async Task The_function_route_documents_the_closed_expression_vocabulary() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement functions = await client.GetFromJsonAsync("/dsl/functions"); + string[] names = [.. functions.EnumerateArray().Select(e => e.GetProperty("name").GetString()!)]; + + names.Should().Contain(["len", "has", "lower", "upper", "contains", + "startsWith", "endsWith", "matches", "coalesce"]); + } + + [Fact] + public async Task The_document_route_reports_each_registered_documents_hash() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement documents = await client.GetFromJsonAsync("/dsl/documents"); + + JsonElement linear = documents.EnumerateArray() + .First(d => d.GetProperty("name").GetString() == "dsl-linear"); + + linear.GetProperty("documentHash").GetString().Should().MatchRegex("^[0-9a-f]{64}$"); + linear.GetProperty("nodes").GetInt32().Should().Be(2); + } + + private async Task ValidateAsync(HttpClient client, string document) + { + using var content = new StringContent(document, Encoding.UTF8, "application/json"); + HttpResponseMessage response = await client.PostAsync("/dsl/validate", content); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(); + } + + [Fact] + public async Task Validate_accepts_a_good_document_without_registering_it() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement result = await ValidateAsync(client, DslDocuments.Branch); + + result.GetProperty("valid").GetBoolean().Should().BeTrue(); + result.GetProperty("name").GetString().Should().Be("dsl-branch"); + result.GetProperty("documentHash").GetString().Should().MatchRegex("^[0-9a-f]{64}$"); + + // Validating must not publish: the catalog is unchanged either way. + JsonElement catalog = await client.GetFromJsonAsync("/workflows"); + catalog.EnumerateArray().Count(w => w.GetProperty("name").GetString() == "dsl-branch") + .Should().Be(1); + } + + [Fact] + public async Task Validate_returns_pointer_accurate_diagnostics() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement result = await ValidateAsync(client, """ + { + "dsl": "abacus.workflow/1.0", + "name": "bad", "version": "1.0.0", "start": "a", + "nodes": [ { "id": "a", "kind": "transform", "set": { "x": "lenn($.y)" } } ], + "edges": [ { "from": "a", "to": "missing" } ] + } + """); + + result.GetProperty("valid").GetBoolean().Should().BeFalse(); + + JsonElement[] diagnostics = [.. result.GetProperty("diagnostics").EnumerateArray()]; + + diagnostics.Should().Contain(d => + d.GetProperty("code").GetString() == DslCodes.UnknownFunction && + d.GetProperty("pointer").GetString() == "/nodes/0/set/x"); + + diagnostics.Should().Contain(d => + d.GetProperty("code").GetString() == DslCodes.EdgeEndpointNotFound && + d.GetProperty("pointer").GetString() == "/edges/0/to"); + + diagnostics.First(d => d.GetProperty("code").GetString() == DslCodes.UnknownFunction) + .GetProperty("suggestion").GetString().Should().Contain("len"); + } + + /// + /// Validating against the live host is the point of the route — it knows which custom nodes are + /// registered, which an offline linter cannot. + /// + [Fact] + public async Task Validate_resolves_custom_nodes_against_the_live_catalog() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement good = await ValidateAsync(client, DslDocuments.Custom); + good.GetProperty("valid").GetBoolean().Should().BeTrue(); + good.GetProperty("skippedChecks").EnumerateArray().Should().BeEmpty(); + + JsonElement bad = await ValidateAsync(client, + DslDocuments.Custom.Replace("\"doubler\"", "\"not-registered\"", StringComparison.Ordinal)); + + bad.GetProperty("valid").GetBoolean().Should().BeFalse(); + bad.GetProperty("diagnostics").EnumerateArray() + .Should().Contain(d => d.GetProperty("code").GetString() == DslCodes.UnknownCustomNode); + } + + [Fact] + public async Task Validate_reports_malformed_json_rather_than_failing_the_request() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement result = await ValidateAsync(client, "{ not json"); + + result.GetProperty("valid").GetBoolean().Should().BeFalse(); + result.GetProperty("diagnostics").EnumerateArray() + .Should().Contain(d => d.GetProperty("code").GetString() == DslCodes.MalformedJson); + } + + [Fact] + public async Task A_root_level_diagnostic_reports_a_usable_pointer() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement result = await ValidateAsync(client, "{ not json"); + + result.GetProperty("diagnostics").EnumerateArray().First() + .GetProperty("pointer").GetString().Should().Be("/"); + } +} diff --git a/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs b/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs new file mode 100644 index 0000000..45d72e4 --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs @@ -0,0 +1,545 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Interpretation; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.IntegrationTests; + +/// +/// End-to-end coverage of every DSL conversion: each node kind, each edge shape, gates, +/// notifications, triggers, audit and failure rules, run through the real host. +/// +public class DslWorkflowTests : IClassFixture +{ + private readonly DslHostFixture _fixture; + + public DslWorkflowTests(DslHostFixture fixture) => _fixture = fixture; + + private static object Context(string orderId = "ORD-1", decimal amount = 100m) + => new { orderId, amount }; + + // ---- linear and the envelope --------------------------------------------------------------- + + [Fact] + public async Task Linear_document_runs_to_completion() + { + using HttpClient client = _fixture.CreateClient(); + + WorkflowInstance instance = await _fixture.RunAsync( + client, "dsl-linear", Context(amount: 50m)); + + instance.Status.Should().Be(InstanceStatus.Completed); + } + + /// + /// The whole reason the envelope carries the start context: an expression several nodes deep can + /// still read it, without every node having to forward it by hand. + /// + [Fact] + public async Task Context_survives_to_the_last_node() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-linear", Context("ORD-CARRY", 21m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + + result!["carried"]!.GetValue().Should().Be("ORD-CARRY"); + result["total"]!.GetValue().Should().Be(42m, "the first node computed amount * 2"); + } + + [Fact] + public async Task A_context_failing_the_declared_schema_is_rejected() + { + using HttpClient client = _fixture.CreateClient(); + + HttpResponseMessage response = await client.PostAsJsonAsync( + "/workflows/dsl-linear/instances", new { context = new { amount = 10 } }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + (await response.Content.ReadAsStringAsync()).Should().Contain("orderId"); + } + + [Fact] + public async Task A_context_matching_the_declared_schema_is_accepted() + { + using HttpClient client = _fixture.CreateClient(); + + HttpResponseMessage response = await client.PostAsJsonAsync( + "/workflows/dsl-linear/instances", new { context = Context() }); + + response.StatusCode.Should().BeOneOf(HttpStatusCode.Accepted, HttpStatusCode.Created, HttpStatusCode.OK); + } + + // ---- edges ------------------------------------------------------------------------------------ + + [Theory] + [InlineData(5000, "large")] + [InlineData(10, "small")] + public async Task Conditional_edges_route_by_predicate(int amount, string expected) + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-branch", Context(amount: amount)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["band"]!.GetValue().Should().Be(expected); + } + + [Fact] + public async Task Fan_out_and_barrier_collect_both_branches() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-fan", Context(amount: 10m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonElement history = await client.GetFromJsonAsync($"/instances/{id}/events/history"); + string ran = string.Join(", ", history.GetProperty("items").EnumerateArray() + .Select(e => $"{(e.TryGetProperty("eventType", out JsonElement t) ? t.GetString() : "?")}" + + $"/{(e.TryGetProperty("executorId", out JsonElement x) ? x.GetString() : "-")}")); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result.Should().NotBeNull($"the run should have produced a result; events were: {ran}"); + result!["branches"].Should().NotBeNull($"the barrier should have aggregated; result was {result.ToJsonString()}"); + + var branches = (JsonArray)result["branches"]!; + + branches.Should().HaveCount(2); + branches.Select(b => b!["side"]!.GetValue()) + .Should().BeEquivalentTo("left", "right"); + branches.Select(b => b!["value"]!.GetValue()) + .Should().BeEquivalentTo(new[] { 11m, 12m }); + } + + [Theory] + [InlineData(0, "first")] + [InlineData(1, "second")] + public async Task Fan_out_selector_picks_a_target_by_index(int pick, string expected) + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-select", Context(amount: pick)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["chosen"]!.GetValue().Should().Be(expected); + } + + // ---- gates ------------------------------------------------------------------------------------- + + [Fact] + public async Task A_gate_below_the_threshold_does_not_trip() + { + using HttpClient client = _fixture.CreateClient(); + + WorkflowInstance instance = await _fixture.RunAsync(client, "dsl-gated", Context(amount: 100m)); + instance.Status.Should().Be(InstanceStatus.Completed); + } + + [Fact] + public async Task A_gate_above_the_threshold_parks_the_instance() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-gated", Context(amount: 50_000m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.AwaitingApproval); + + JsonElement approvals = await client.GetFromJsonAsync($"/instances/{id}/approvals"); + approvals.GetProperty("items").EnumerateArray().Should().ContainSingle(); + + JsonElement approval = approvals.GetProperty("items").EnumerateArray().First(); + approval.GetProperty("executorId").GetString().Should().Be("settle"); + approval.GetProperty("reason").GetString().Should().Be("RegulatedSettlement"); + } + + [Fact] + public async Task An_approved_gate_resumes_the_run() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-gated-open", Context(amount: 60_000m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.AwaitingApproval); + + JsonElement approvals = await client.GetFromJsonAsync($"/instances/{id}/approvals"); + string approvalId = approvals.GetProperty("items").EnumerateArray().First().GetProperty("approvalId").GetString()!; + + HttpResponseMessage decision = await client.PostAsJsonAsync( + $"/approvals/{approvalId}/decision", new { decision = "approve" }); + + decision.EnsureSuccessStatusCode(); + + WorkflowInstance instance = await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + instance.Status.Should().Be(InstanceStatus.Completed); + } + + /// An approval node carries a gate whether or not the document spelled the block out. + [Fact] + public async Task An_approval_node_always_parks() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-approval", Context(amount: 1m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.AwaitingApproval); + + JsonElement approvals = await client.GetFromJsonAsync($"/instances/{id}/approvals"); + approvals.GetProperty("items").EnumerateArray().First().GetProperty("executorId").GetString().Should().Be("sign-off"); + } + + // ---- http --------------------------------------------------------------------------------------- + + [Fact] + public async Task Http_node_calls_out_and_projects_the_response() + { + _fixture.Http.Respond = _ => (HttpStatusCode.OK, """{ "reference": "LDG-77" }"""); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-http", Context("ORD-HTTP", 33m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["status"]!.GetValue().Should().Be(200); + result["reference"]!.GetValue().Should().Be("LDG-77"); + } + + [Fact] + public async Task Http_node_renders_url_headers_and_body_templates() + { + _fixture.Http.Requests.Clear(); + _fixture.Http.Respond = _ => (HttpStatusCode.OK, """{ "reference": "X" }"""); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-http", Context("ORD-TEMPLATE", 77m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + _fixture.Http.Requests.TryDequeue(out HttpRequestMessage? request).Should().BeTrue(); + + request!.RequestUri!.ToString().Should().EndWith("/v1/orders/ORD-TEMPLATE"); + request.Headers.GetValues("X-Order").Should().Equal("ORD-TEMPLATE"); + request.Method.Should().Be(HttpMethod.Post); + } + + /// The framework's idempotency key still travels — the DSL did not fork the HTTP path. + [Fact] + public async Task Http_node_still_sends_an_idempotency_key() + { + _fixture.Http.Requests.Clear(); + _fixture.Http.Respond = _ => (HttpStatusCode.OK, "{}"); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-http", Context("ORD-IDEM", 5m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + _fixture.Http.Requests.TryDequeue(out HttpRequestMessage? request).Should().BeTrue(); + request!.Headers.Contains("Idempotency-Key").Should().BeTrue(); + } + + [Fact] + public async Task A_non_json_response_body_still_lands_on_the_envelope() + { + _fixture.Http.Respond = _ => (HttpStatusCode.OK, "plain text"); + + using HttpClient client = _fixture.CreateClient(); + + WorkflowInstance instance = await _fixture.RunAsync(client, "dsl-http", Context(amount: 1m)); + instance.Status.Should().Be(InstanceStatus.Completed); + } + + // ---- llm ------------------------------------------------------------------------------------------- + + [Fact] + public async Task Llm_node_calls_the_model_and_projects_text_and_usage() + { + _fixture.Chat.Reply = "One order, summarised."; + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-llm", Context("ORD-LLM", 9m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["summary"]!.GetValue().Should().Be("One order, summarised."); + result["inputTokens"]!.GetValue().Should().Be(11); + result["model"]!.GetValue().Should().Be("stub-model"); + } + + [Fact] + public async Task Llm_node_renders_its_prompt_templates() + { + _fixture.Chat.Prompts.Clear(); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-llm", Context("ORD-PROMPT", 12m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + _fixture.Chat.Prompts.Should().Contain(p => p.Contains("ORD-PROMPT", StringComparison.Ordinal)); + _fixture.Chat.Prompts.Should().Contain(p => p.Contains("You summarise orders", StringComparison.Ordinal)); + } + + // ---- delay ------------------------------------------------------------------------------------------ + + /// + /// A delay is about when the next node runs, not about what it receives, so the envelope must + /// pass through rather than being replaced by a timer record. + /// + [Fact] + public async Task Delay_node_schedules_a_timer_and_passes_the_envelope_through() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-delay", Context(amount: 1m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["carried"]!.GetValue().Should().Be("before"); + + _fixture.Timers.Scheduled.Should().Contain(t => t.InstanceId == id && t.ExecutorId == "wait"); + } + + // ---- domain events ------------------------------------------------------------------------------------- + + [Fact] + public async Task Publish_node_emits_a_domain_event_and_passes_input_through() + { + var broker = _fixture.Resolve(); + var received = new List(); + + await using IAsyncDisposable subscription = await broker.SubscribeAsync( + new DomainEventSubscriptionOptions { TopicFilter = "dsl.orders.priced" }, + (delivery, _) => + { + lock (received) { received.Add(delivery.Message); } + return ValueTask.FromResult(DeliveryResult.Ack); + }, + default); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-publish", Context("ORD-PUB", 3m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + lock (received) + { + if (received.Count > 0) break; + } + + await Task.Delay(25); + } + + lock (received) + { + received.Should().ContainSingle(); + received[0].Topic.Should().Be("dsl.orders.priced"); + received[0].CorrelationKey.Should().Be("ORD-PUB"); + received[0].PayloadJson.Should().Contain("ORD-PUB"); + } + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["status"]!.GetValue().Should().Be("published"); + } + + [Fact] + public async Task Wait_event_node_parks_then_resumes_with_the_payload() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-wait", Context("ORD-WAIT", 1m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.AwaitingInput); + + HttpResponseMessage published = await client.PostAsJsonAsync("/events", new + { + topic = "dsl.payment.settled", + payload = new { amount = 250 } + }); + + published.EnsureSuccessStatusCode(); + + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["paidAmount"]!.GetValue().Should().Be(250m); + result["order"]!.GetValue().Should().Be("ORD-WAIT", + "the envelope's context survives the park and resume"); + } + + // ---- custom nodes ------------------------------------------------------------------------------------- + + [Fact] + public async Task A_registered_custom_node_runs_with_its_parameters() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-custom", Context(amount: 7m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["value"]!.GetValue().Should().Be(21m, "times was 3"); + } + + // ---- notifications --------------------------------------------------------------------------------------- + + [Fact] + public async Task A_node_notification_reaches_the_event_log() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-notify", Context("ORD-NOTIFY", 4m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonElement history = await client.GetFromJsonAsync($"/instances/{id}/events/history"); + + JsonElement[] custom = [.. history.GetProperty("items").EnumerateArray() + .Where(e => e.TryGetProperty("eventType", out JsonElement t) && t.GetString() == "custom.priced")]; + + custom.Should().ContainSingle( + "the event log held: " + string.Join(", ", history.GetProperty("items").EnumerateArray() + .Select(e => e.TryGetProperty("eventType", out JsonElement t) ? t.GetString() : "?"))); + // The payload travels as raw JSON, so read it back rather than assuming how the endpoint + // chose to embed it. + JsonElement raw = custom[0].GetProperty("payloadJson"); + JsonNode payload = (raw.ValueKind == JsonValueKind.String + ? JsonNode.Parse(raw.GetString()!) + : JsonNode.Parse(raw.GetRawText()))!; + + payload["total"]!.GetValue().Should().Be(8m); + payload["order"]!.GetValue().Should().Be("ORD-NOTIFY"); + } + + [Fact] + public async Task The_catalog_advertises_workflow_defined_notifications() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement workflow = await client.GetFromJsonAsync("/workflows/dsl-notify"); + workflow.GetRawText().Should().Contain("priced"); + } + + // ---- failure classification ------------------------------------------------------------------------------- + + [Fact] + public async Task A_documents_failure_rule_dead_stops_the_run() + { + _fixture.Http.Respond = _ => (HttpStatusCode.BadRequest, """{ "error": "nope" }"""); + + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-failing", Context(amount: 1m)); + WorkflowInstance instance = await _fixture.WaitForStatusAsync( + id, InstanceStatus.DeadStopped, InstanceStatus.Failed); + + instance.Status.Should().Be(InstanceStatus.DeadStopped); + instance.AttemptCount.Should().BeLessThanOrEqualTo(1, "a dead stop must not burn attempts discovering it"); + } + + // ---- audit ---------------------------------------------------------------------------------------------------- + + [Fact] + public async Task A_document_declaring_an_audit_block_gets_an_audit_record_shape() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-audited", Context(amount: 1m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonElement state = await client.GetFromJsonAsync( + $"/workflows/dsl-audited/instances/{id}/state"); + + state.TryGetProperty("audit", out JsonElement audit).Should().BeTrue(); + audit.ValueKind.Should().NotBe(JsonValueKind.Null); + } + + [Fact] + public async Task A_document_with_no_audit_block_reports_no_record() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-linear", Context(amount: 1m)); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonElement state = await client.GetFromJsonAsync( + $"/workflows/dsl-linear/instances/{id}/state"); + + if (state.TryGetProperty("audit", out JsonElement audit)) + { + audit.ValueKind.Should().Be(JsonValueKind.Null); + } + } + + // ---- triggers -------------------------------------------------------------------------------------------------- + + [Fact] + public async Task A_published_event_starts_a_triggered_document() + { + using HttpClient client = _fixture.CreateClient(); + + var instances = _fixture.Resolve(); + Page before = await instances.QueryAsync( + new InstanceQuery { WorkflowName = "dsl-triggered" }, default); + + HttpResponseMessage published = await client.PostAsJsonAsync("/events", new + { + topic = "dsl.orders.placed", + payload = new { orderId = "ORD-TRIGGER", amount = 5 } + }); + + published.EnsureSuccessStatusCode(); + + DateTime deadline = DateTime.UtcNow.AddSeconds(15); + Page after = before; + + while (DateTime.UtcNow < deadline) + { + after = await instances.QueryAsync( + new InstanceQuery { WorkflowName = "dsl-triggered" }, default); + + if (after.Total > before.Total) break; + await Task.Delay(50); + } + + after.Total.Should().BeGreaterThan(before.Total, "the topic should have started an instance"); + } + + // ---- the catalog ------------------------------------------------------------------------------------------------ + + [Fact] + public async Task Every_document_appears_in_the_workflow_catalog() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement catalog = await client.GetFromJsonAsync("/workflows"); + string[] names = [.. catalog.EnumerateArray().Select(w => w.GetProperty("name").GetString()!)]; + + foreach ((string _, string text) in DslDocuments.All) + { + string name = JsonNode.Parse(text)!["name"]!.GetValue(); + names.Should().Contain(name); + } + } + + [Fact] + public async Task A_dsl_workflow_exposes_its_nodes_to_the_catalog() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement nodes = await client.GetFromJsonAsync( + "/workflows/dsl-gated/versions/1.0.0/nodes"); + + string raw = nodes.GetRawText(); + raw.Should().Contain("prepare").And.Contain("settle"); + } +} From c9121c2033d9265b5dc3fe58650370a6b7ff0786 Mon Sep 17 00:00:00 2001 From: Ninja Date: Mon, 17 Aug 2026 22:37:41 +0100 Subject: [PATCH 6/8] docs(dsl): phase 5 - wiki chapter, README, and a worked example Documents the DSL where the compiled path is documented, so the two are legible side by side - the honest reason to pick one over the other is what a reader most needs. The wiki chapter sits beside "Authoring a workflow" and covers the envelope, the expression language and its semantics, every node kind and what it puts on the envelope, the custom-node seam, registration, the two validation phases with their diagnostic codes, version immutability, the routes, the limits, and - stated plainly - what the DSL does not do. Ships example-order.workflow.json beside the compiled example, with tests asserting it validates and registers. Documentation people copy should fail here rather than in their editor. A parity fixture runs the same work authored both ways on one host and asserts identical results, including on values where binary floating point would diverge from the compiled path. That is the clearest available statement that the DSL is a front end and not a fork. One deviation, recorded in the plan: the parity pair is not ExampleOrderWorkflow. That workflow sums an array of order lines and the DSL has no iteration, which is exactly the limitation the design records - found by trying to hit it. Suites: 723 unit, 358 DSL unit, 209 integration (+59), 7 chaos. --- README.md | 61 ++++ .../07-workflow-dsl-implementation-plan.md | 17 +- docs/wiki.md | 298 ++++++++++++++++++ .../Properties/launchSettings.json | 12 + .../ExampleOrder/example-order.workflow.json | 72 +++++ .../DslHostingTests.cs | 54 ++++ .../DslParityTests.cs | 251 +++++++++++++++ 7 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 src/Abacus.Data.Service/Properties/launchSettings.json create mode 100644 src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json create mode 100644 tests/Abacus.Run.IntegrationTests/DslParityTests.cs diff --git a/README.md b/README.md index ded4847..21ea2fa 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,65 @@ is bound. Both are verified against real servers in `tests/Abacus.Run.BrokerTest Full walkthrough: [Events, history, and SSE](docs/wiki.md#events-history-and-sse) and [Event broker](docs/wiki.md#event-broker-and-event-driven-workflows). +## Authoring with the DSL + +A workflow can be a **JSON document** instead of C#: validated against a published schema, +interpreted at build time, and registered exactly like a compiled definition. Same graph, same +executors, same gates, same events — the DSL is a second front end onto the runtime, not a fork. + +The governing rule is that **the DSL composes but never computes**. A document declares which nodes +exist, how they connect, and when an edge is taken; it carries no behaviour. Every unit of work is a +capability the host already shipped, so the answer to "the DSL cannot express this" is always +*register a node*, never *embed a script*. + +```json +{ + "dsl": "abacus.workflow/1.0", + "name": "order-settlement", + "version": "1.0.0", + "context": { "type": "object", "required": ["orderId", "amount"] }, + "start": "price", + "output": ["settle"], + "nodes": [ + { "id": "price", "kind": "transform", "set": { "total": "$ctx.amount * 1.2" } }, + { "id": "settle", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/settlements", + "allowedHosts": ["ledger.internal"], + "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}", + "gate": { "mode": "conditional", "when": "$.total > 25000", "reason": "RegulatedSettlement" } } + ], + "edges": [ { "from": "price", "to": "settle" } ] +} +``` + +```csharp +builder.Services.AddWorkflowHost(configuration) + .AddWorkflow() // compiled, unchanged + .UseDsl() + .AddDslNode(new RiskScoringNodeFactory()) // extend the vocabulary + .AddDslWorkflowsFromDirectory("workflows/"); // compose it + +app.MapDslApi(); +``` + +Node kinds cover `transform`, `http`, `llm`, `delay`, `approval`, `publish`, `wait-event`, `fan-in` +and `custom`. Expressions are a closed, total language — absence is a value rather than an exception, +conditions are strictly boolean, and arithmetic is decimal. Validation runs in two phases, and every +diagnostic carries a JSON Pointer: + +``` +DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'? +DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'? +``` + +An invalid document fails startup. A published `(name, version)` is immutable, enforced by a +canonical hash of the document. Routes: `GET /dsl/schema`, `/dsl/nodes`, `/dsl/functions`, +`/dsl/documents`, and `POST /dsl/validate`. + +Full walkthrough: [Authoring with the DSL](docs/wiki.md#authoring-with-the-dsl). +Schema: [docs/schema/abacus-workflow-dsl-1.0.json](docs/schema/abacus-workflow-dsl-1.0.json). + ## Audit records Events record what the runtime did. An audit record answers the separate question of why a run's @@ -549,8 +608,10 @@ at startup. | `src/Abacus.Run` | Headless framework: workflow runtime, dispatch, executors, middleware, in-memory store defaults, and HTTP API endpoints | | `src/Abacus.Adapters.Cache.Redis` | Redis adapters: Streams event bus, workflow event broker, cross-replica control channel | | `src/Abacus.Adapters.Messaging.RabbitMQ` | RabbitMQ adapter: topic-exchange workflow event broker | +| `src/Abacus.Run.Dsl` | Declarative authoring: JSON Schema validation, the AbEx expression language, and the document interpreter | | `src/Abacus.Run.Service` | Deployable host: control-plane UI, SQL Server stores, the SQLite audit-record store, startup wiring, and the example workflow | | `tests/Abacus.Run.UnitTests` | Unit coverage for runtime behavior; references the library only | +| `tests/Abacus.Run.DslTests` | Expression, validation and interpreter coverage for the DSL | | `tests/Abacus.Run.IntegrationTests` | HTTP, control-plane, and architecture-boundary coverage against the real host | | `tests/Abacus.Run.ChaosTests` | Failure and lifecycle resilience coverage | | `tests/Abacus.Run.BrokerTests` | The distributed brokers against real Redis and RabbitMQ, via Testcontainers | diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md index 01142aa..256954b 100644 --- a/docs/implementation/07-workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -11,13 +11,13 @@ workflow behaves; the DSL is a second front end onto the runtime that already ex | 2 | Document model and validation | Parser, JSON Schema, semantic validator, diagnostics | 1 | ✅ Done | | 3 | Interpreter | `DslWorkflowDefinition`, node factories, graph construction | 1, 2 | ✅ Done | | 4 | Host integration | Registration, `IContextValidatingWorkflow`, catalog and validate endpoints | 3 | ✅ Done | -| 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ⬜ Not started | +| 5 | Documentation and worked example | Wiki chapter, README, a shipped example document | 4 | ✅ Done | | 6 | Deferred | Runtime publication API, sub-workflows, iteration | 5 | ⬜ Out of scope | ## Status -Phases 1–4 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, **201 integration** -(+51), **7 chaos**. +Phases 1–5 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, **209 integration** +(+59), **7 chaos**. | Delivered | Where | | --------- | ----- | @@ -31,6 +31,8 @@ Phases 1–4 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, * | `IContextValidatingWorkflow`, consulted after the type bind | [Core/WorkflowRegistry.cs](../../src/Abacus.Run/Core/WorkflowRegistry.cs) | | `ITemplateBindingSource` | [Executors/TemplateEngine.cs](../../src/Abacus.Run/Executors/TemplateEngine.cs) | | `/dsl/schema`, `/dsl/nodes`, `/dsl/functions`, `/dsl/documents`, `/dsl/validate` | [Hosting/DslEndpoints.cs](../../src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs) | +| Wiki chapter, README section, project-layout rows | [wiki.md](../wiki.md#authoring-with-the-dsl), [README.md](../../README.md) | +| Shipped example document | [example-order.workflow.json](../../src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json) | ### Deviations from the plan as written @@ -60,6 +62,15 @@ the released messages separately. The DSL node therefore holds arrivals and emit lands, with the expected count read from the document. See the note below — the framework's own `FanInExecutor` has the same problem and does not work with a barrier edge. +**The parity test is not `ExampleOrderWorkflow`.** The plan said to express the shipped example as a +document and assert both produce the same result. It cannot be expressed: `ExampleOrderWorkflow` sums +an array of order lines, and the DSL has no iteration — which is exactly the limitation §11 of the +design records, found by trying to hit it. The parity pair is instead a purpose-built workflow +authored both ways, which still makes the point it was there to make: same runtime, same answer, two +front ends. It also pins decimal arithmetic across both, where a DSL quietly using binary floating +point would diverge. The shipped `example-order.workflow.json` is a document the DSL *can* express, +covered by tests asserting it validates and registers. + **Trigger `correlationKey` is a literal, not an expression.** A trigger subscription is registered before any message exists, so there is nothing for a path to read. The validator now warns when one is written to look like an expression rather than silently evaluating or silently dropping it. diff --git a/docs/wiki.md b/docs/wiki.md index 435ada4..6c4b331 100644 --- a/docs/wiki.md +++ b/docs/wiki.md @@ -17,6 +17,9 @@ This page is the repository-level technical wiki. It documents the implementatio - [Edges](#edges) · [Approval gates on a node](#approval-gates-on-a-node) · [Events on a node](#events-on-a-node) - [Failure classification](#failure-classification) · [Engine context](#engine-context-inside-an-executor) · [Middleware](#middleware) - [A definition using all of it](#a-definition-using-all-of-it) · [Versioning rules](#versioning-rules-that-bite) +- [Authoring with the DSL](#authoring-with-the-dsl) + - [Which one to reach for](#which-one-to-reach-for) · [The envelope](#the-envelope) · [AbEx](#abex-the-expression-language) + - [Node kinds](#node-kinds) · [Custom nodes](#custom-nodes--the-extension-seam) · [Validation](#validation) - [Workflow audit records](#workflow-audit-records) - [Registering workflows and middleware](#registering-workflows-and-middleware) - [Instance lifecycle](#instance-lifecycle) @@ -672,6 +675,301 @@ public sealed class OrderWorkflow - Two definitions registered with the same name and version fail startup rather than one silently winning. + +## Authoring with the DSL + +Everything above authors a workflow in C#. This authors one as a **JSON document**: validated against +a published schema, interpreted at build time, and registered exactly like a compiled definition. +Nothing about the runtime changes — same graph, same executors, same gates, same events. + +> **The governing rule: the DSL composes, it never computes.** +> +> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never +> carries behaviour. Every unit of work a DSL workflow performs is a capability the host already +> shipped — a built-in node kind, or a custom node registered by name. + +That is what makes a document safe to accept from outside the build and honest about its ceiling. +The answer to "the DSL cannot express this" is always *register a node*, never *embed a script*. + +### Which one to reach for + +They are peers, not a replacement. A realistic system uses both: engineers ship nodes, and workflows +wire them together. + +| | Compiled definition | DSL document | +| --- | --- | --- | +| **Authored by** | An engineer with a build pipeline | Anyone with the schema | +| **Expresses** | Arbitrary behaviour | Composition of registered behaviour | +| **Typing** | Compile-time, generic | Runtime, JSON Schema per node | +| **Changed by** | A release | An edited document | +| **Ceiling** | The language | The registered node catalog | +| **Best for** | Domain logic, novel executors | Orchestration, per-tenant variation, fast iteration | + +### A document end to end + +```json +{ + "dsl": "abacus.workflow/1.0", + "name": "order-settlement", + "version": "1.2.0", + + "context": { + "type": "object", + "required": ["orderId", "amount"], + "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } } + }, + + "start": "price", + "output": ["settle"], + + "nodes": [ + { "id": "price", "kind": "transform", + "set": { "total": "$ctx.amount * 1.2" }, + "notify": { "name": "priced", "payload": { "total": "$.total" } } }, + + { "id": "settle", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/settlements", + "allowedHosts": ["ledger.internal"], + "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}", + "gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance"], + "expiresAfter": "PT8H", + "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] }, + "locked": true + } } + ], + + "edges": [ { "from": "price", "to": "settle", "when": "$.total > 0" } ], + + "triggers": [ { "topic": "orders.placed" } ], + "notifications": { "level": "standard", "stream": true }, + "onFailure": [ { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, + "disposition": "retry" } ], + "audit": { "sections": ["submission", "outcome"] }, + "limits": { "maxAttempts": 5 } +} +``` + +`dsl` is a versioned media identifier, not decoration: it selects the schema and the interpreter, and +a major version this interpreter does not read is refused rather than half-understood. + +### The envelope + +Every DSL node sends and receives one message type, so every edge type-checks by construction: + +```json +{ "ctx": { "orderId": "ORD-1", "amount": 100 }, + "data": { "total": 120 }, + "meta": { "node": "price", "superstep": 1 } } +``` + +- **`ctx`** — the start context, frozen at the beginning and copied through unchanged. This is why an + expression eleven nodes deep can still read `$ctx.orderId`. A compiled node closes over whatever + C# scope it likes; a document has no scope, so the envelope carries one. +- **`data`** — the current value: what a node reads and what it replaces. +- **`meta`** — provenance the interpreter maintains. + +The workflow's **result is `data`**, not the envelope. The context is machinery, not an answer. + +### AbEx, the expression language + +Conditions, guards, correlation keys and projections all need some computation. The grammar is +closed: total (no exceptions), pure (no I/O), and statically checkable, so a typo fails a document +review rather than a production run. + +**Roots.** `$` is the current `data`, `$ctx` the frozen start context, `$run` the run's identity +(`instanceId`, `tenantId`, `workflow`, `version`, `attempt`, `superstep`, `now`). + +There is deliberately no `$node.`. The engine is message-passing, so a prior node's output is not +ambiently available and a root that pretended otherwise would be a lie. Carry values forward in +`data` — that is what a `transform` node is for. + +**Operators**, loosest to tightest: `||`, `&&`, comparison, `+ -`, `* / %`, unary `!` and `-`. +Comparison is non-associative: `a < b < c` is refused rather than silently comparing a boolean to a +number. + +**Functions** — the whole list, and an unknown name is a validation error with a nearest-match +suggestion: + +| Function | Result | +| --- | --- | +| `len(x)` | Length of a string, array or object; `0` otherwise | +| `has(path)` | Whether the path resolved to anything at all | +| `lower(s)` / `upper(s)` | Case folding, invariant culture | +| `contains(s, sub)`, `startsWith(s, p)`, `endsWith(s, p)` | Ordinal string tests | +| `matches(s, pattern)` | Regex. The pattern must be a **string literal**, and matching times out at 200 ms | +| `coalesce(a, b, …)` | First argument that is neither absent nor null | +| `number(x)`, `string(x)`, `bool(x)` | Explicit coercion | + +**Semantics worth knowing before you are surprised by them:** + +- **Absence is a value.** A path that does not resolve yields *absent*, which never throws. +- **Absence makes every comparison false — including `!=`.** Asking whether a field you never set + differs from a value should not be answered "yes". Use `has()` to ask about presence. +- **Conditions are strictly boolean.** Only `true` is true. Absent, `null`, `0` and `""` are all + false. There is no truthiness ladder. +- **Comparison is JSON-typed.** Number-to-number is numeric, string-to-string is ordinal, anything + cross-type is false. No coercion ladder. +- **Arithmetic is decimal, and numbers only.** These documents price orders, so binary floating point + is the wrong default — `0.1 + 0.2` is `0.3`. `+` does not concatenate strings; that is what + templates are for. +- **Division by zero yields absent**, not an error. + +**Determinism.** `$run.now` is **forbidden in edge conditions and gate predicates**, and permitted in +templates. `BuildAsync` runs once per attempt, and a resumed instance must retrace the routing its +checkpoint recorded; a condition reading the clock could take a different branch, which is silent, +intermittent and close to undebuggable. The validator refuses it by static inspection. + +**Templates.** A `{{ … }}` placeholder in a URL, header, body or prompt evaluates a full AbEx +expression: `{{ $ctx.orderId }}`, `{{ $.total * 1.2 }}`. An absent placeholder renders empty. A bare +string in `when`, `set` or `correlationKey` is AbEx directly — no field accepts both conventions. + +### Node kinds + +| `kind` | Maps to | Produces in `data` | +| --- | --- | --- | +| `transform` | `TransformExecutor` | The `set` map merged into `data` (or replacing it) | +| `http` | `ApiCallExecutor` | `{ status, body }` | +| `llm` | `LlmExecutor` | `{ text, value, model, inputTokens, outputTokens, costUsd, finishReason, elapsedMs }` | +| `delay` | `DelayExecutor` | Unchanged — a delay is about *when*, not *what* | +| `approval` | `HumanApprovalExecutor` | Unchanged; the node exists to be the place a human decides | +| `publish` | `PublishDomainEventExecutor` | Unchanged; publishing is a side effect on the way past | +| `wait-event` | `WaitForDomainEventExecutor` | The delivered payload | +| `fan-in` | Barrier aggregation | `{ : [ …each branch's data… ] }` | +| `custom` | A registered `IDslNodeFactory` | Whatever the factory's executor produces | + +There is **no `delegate` kind**, and there never will be. Arbitrary code is precisely what a document +must not carry. + +`http` and `llm` are the framework's own executors, hosted inside the DSL node — the egress +allow-list, the `Idempotency-Key`, structured output, cost accounting and `llm.completed` all behave +exactly as they do for a compiled workflow. + +### Custom nodes — the extension seam + +```csharp +public sealed class RiskScoringNodeFactory : IDslNodeFactory +{ + public string Name => "score-risk"; + + public JsonNode? ParameterSchema => JsonNode.Parse(""" + { "type": "object", "required": ["threshold"], + "properties": { "threshold": { "type": "number" } } } + """); + + public IHostExecutor Create(DslNodeContext context) + => new RiskScorer(context.Node.Id, context.Parameters["threshold"]!.GetValue()); +} +``` + +```json +{ "id": "score", "kind": "custom", "node": "score-risk", "with": { "threshold": 0.82 } } +``` + +The executor must be a `HostExecutor` and must use the id the document +declared — gate policy and node state key off it. `ParameterSchema` is validated against `with` at +**registration**, so a bad parameter fails startup rather than surprising a run. + +### Registration + +```csharp +builder.Services.AddWorkflowHost(config) + .AddWorkflow() // compiled, unchanged + .UseDsl() // routes work before any document exists + .AddDslNode(new RiskScoringNodeFactory()) + .AddDslWorkflow("workflows/order-settlement.json") + .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json"); + +app.MapWorkflowApi(); +app.MapDslApi(); +``` + +Order does not matter: documents are validated once the container is built, against the *complete* +node catalog. A document that fails validation **fails startup**, with every diagnostic logged — the +same place a bad compiled workflow fails. + +### Validation + +Two phases, because one cannot do the job. + +**JSON Schema** checks shape — required properties, `kind`-discriminated variants, id and SemVer +patterns. Published at `docs/schema/abacus-workflow-dsl-1.0.json` and served from `GET /dsl/schema`, +so an editor gives completion and inline errors before the document reaches a host. + +**The semantic validator** checks everything a schema cannot express, each with a stable code: + +| Codes | Cover | +| --- | --- | +| `DSL01xx` | DSL version, malformed JSON, schema violations, hash conflicts | +| `DSL02xx` | Duplicate ids, unknown `start`/`output`/edge endpoints, duplicate edges | +| `DSL03xx` | Unreachable nodes, dead ends, cycles with nothing that yields, unreachable barrier sources | +| `DSL04xx` | Expression parsing, unknown functions, non-deterministic conditions, depth | +| `DSL05xx` | Gates on non-gateable kinds, conditional gates with no predicate, escalation with no assignees | +| `DSL06xx` | Unregistered custom nodes, bad `with` parameters, missing egress hosts | +| `DSL07xx` | Document, node, edge and expression limits | + +Every diagnostic carries a JSON Pointer: + +``` +DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'? +DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'? +DSL0301 warn /nodes/7 Node 'notify' is unreachable from 'price'. +``` + +A cycle is refused only when nothing on it yields — polling and wait-and-recheck are legitimate, but +a cycle of pure compute nodes is a hot spin. Put a `delay`, `wait-event` or `approval` node on it. + +Environment-dependent checks report as **skipped** rather than passed when there is no host to check +against, because a check that silently did not run is worse than one that openly did not. + +### Versions are immutable + +A document registers as `(name, version)` like any workflow, and the framework's rule applies: +**a published version is immutable.** Identity is a canonical SHA-256 (RFC 8785) of the document — +reformatting and property reordering do not change it, one byte of behaviour does. Registering a +document whose `(name, version)` is known with a different hash is a startup failure naming both +hashes. Editing a workflow means bumping the version. + +It also answers the operational question directly: *is this instance running the document I am +looking at?* `GET /dsl/documents` reports each registered document's hash. + +### Routes + +| Route | Purpose | +| --- | --- | +| `GET /dsl/schema` | The published JSON Schema, for editor completion | +| `GET /dsl/nodes` | Built-in kinds and every registered custom node, with parameter schemas | +| `GET /dsl/functions` | The closed expression vocabulary, with arities | +| `GET /dsl/documents` | Registered documents and their hashes | +| `POST /dsl/validate` | Validate without registering — what an authoring tool calls | + +`POST /dsl/validate` reflects the host's registered node names back to the caller, so it takes the +same authorization as the catalog routes. + +### Limits + +Document ≤ 1 MB, nodes ≤ 500, edges ≤ 2000, expression depth ≤ 32, regex match ≤ 200 ms. All +configurable down through `ConfigureDsl`, none up. + +### What the DSL does not do + +Stated plainly, so you meet the boundary here rather than in an error message: + +- **No loops or iteration.** There is no `foreach`, and no way to sum an array. Fan-out over branches + is the intended shape. Unbounded iteration in a checkpointed engine has real semantics to work out + first. +- **No sub-workflows.** The engine supports composing workflows; resolving and version-pinning one + document from another needs its own design. +- **No runtime publication.** Documents load from disk at startup. A management API that accepts them + at run time changes the registry from immutable to mutable, which touches version resolution, + dispatch, authorization and tenancy. +- **No export from C#.** A compiled definition cannot be emitted as a document. The DSL is a + different way in, not a serialization of the compiled path. + ## Workflow audit records Events answer "what did the runtime do". An audit record answers "why is this result defensible" — diff --git a/src/Abacus.Data.Service/Properties/launchSettings.json b/src/Abacus.Data.Service/Properties/launchSettings.json new file mode 100644 index 0000000..4b4b156 --- /dev/null +++ b/src/Abacus.Data.Service/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Abacus.Data.Service": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59192;http://localhost:59193" + } + } +} \ No newline at end of file diff --git a/src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json b/src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json new file mode 100644 index 0000000..fefd6e9 --- /dev/null +++ b/src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json @@ -0,0 +1,72 @@ +{ + "dsl": "abacus.workflow/1.0", + "name": "example-order-dsl", + "version": "1.0.0", + "description": "The shipped example, authored as a document instead of C#.", + + "context": { + "type": "object", + "required": ["orderId", "amount"], + "properties": { + "orderId": { "type": "string", "minLength": 1 }, + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "default": "GBP" } + } + }, + + "start": "price", + "output": ["settle"], + + "nodes": [ + { + "id": "price", + "kind": "transform", + "description": "Applies VAT and carries the order forward.", + "set": { + "orderId": "$ctx.orderId", + "net": "$ctx.amount", + "vat": "$ctx.amount * 0.2", + "total": "$ctx.amount * 1.2", + "currency": "coalesce($ctx.currency, 'GBP')" + }, + "notify": { + "name": "priced", + "payload": { "order": "$.orderId", "total": "$.total" } + } + }, + { + "id": "settle", + "kind": "transform", + "description": "Settles the order. Large ones need a human first.", + "set": { + "orderId": "$.orderId", + "total": "$.total", + "status": "'settled'" + }, + "gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "LargeSettlement", + "requireApprovers": 1, + "expiresAfter": "PT8H", + "onExpiry": { "action": "deadStop" } + } + } + ], + + "edges": [ + { "from": "price", "to": "settle" } + ], + + "notifications": { + "level": "standard", + "stream": true + }, + + "onFailure": [ + { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, "disposition": "retry" }, + { "match": { "exception": "WorkflowValidationException" }, "disposition": "deadStop" } + ], + + "limits": { "maxAttempts": 3 } +} diff --git a/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs index daf4320..5aff628 100644 --- a/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs +++ b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs @@ -178,6 +178,60 @@ public void A_document_from_disk_registers() } } + /// + /// The document shipped beside the compiled example. It is documentation people will copy, so a + /// change that invalidates it should fail here rather than in someone's editor. + /// + [Fact] + public void The_shipped_example_document_is_valid() + { + string? root = FindRepositoryRoot(); + root.Should().NotBeNull(); + + string path = Path.Combine(root!, "src", "Abacus.Run.Service", "Workflows", + "ExampleOrder", "example-order.workflow.json"); + + File.Exists(path).Should().BeTrue($"the shipped example should be at {path}"); + + DslParseResult result = DslParser.ParseFile(path, new DslEnvironment { EnforceEgress = false }); + + result.IsValid.Should().BeTrue(result.Validation.Describe()); + result.Document!.Name.Should().Be("example-order-dsl"); + } + + [Fact] + public void The_shipped_example_document_registers_and_builds() + { + string? root = FindRepositoryRoot(); + string path = Path.Combine(root!, "src", "Abacus.Run.Service", "Workflows", + "ExampleOrder", "example-order.workflow.json"); + + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflow(path)); + + IWorkflowDefinition[] definitions = [.. provider.GetServices()]; + definitions.Should().ContainSingle(); + definitions[0].Name.Should().Be("example-order-dsl"); + } + + private static string? FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory.FullName, "docs", "schema"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + return null; + } + [Fact] public void Registering_a_duplicate_node_name_is_refused() { diff --git a/tests/Abacus.Run.IntegrationTests/DslParityTests.cs b/tests/Abacus.Run.IntegrationTests/DslParityTests.cs new file mode 100644 index 0000000..dc4da5f --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslParityTests.cs @@ -0,0 +1,251 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Api; +using Abacus.Run.Core; +using Abacus.Run.Dsl.Hosting; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Abacus.Run.IntegrationTests; + +public sealed record ParityContext(string OrderId = "ORD-1", decimal Amount = 100m); + +public sealed record ParityResult(string OrderId, decimal Net, decimal Vat, decimal Total); + +/// +/// The compiled half of the parity pair. Deliberately trivial arithmetic: the point is that both +/// front ends reach the same runtime and produce the same answer, not that the sum is interesting. +/// +public sealed class ParityWorkflow : IWorkflowDefinition +{ + public string Name => "parity-compiled"; + public string Version => "1.0.0"; + + public ValueTask BuildAsync(WorkflowBuildContext context, CancellationToken cancellationToken) + { + ExecutorBinding price = context.Node(new Price("price")); + ExecutorBinding settle = context.Node(new Settle("settle")); + + return new ValueTask(new WorkflowBuilder(price) + .AddEdge(price, settle) + .WithOutputFrom(settle) + .WithName(Name) + .Build()); + } + + private sealed class Price(string id) : HostExecutor(id) + { + protected override ValueTask ExecuteCoreAsync( + ParityContext input, IWorkflowContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(new ParityResult( + input.OrderId, input.Amount, input.Amount * 0.2m, input.Amount * 1.2m)); + } + + private sealed class Settle(string id) : HostExecutor(id) + { + protected override ValueTask ExecuteCoreAsync( + ParityResult input, IWorkflowContext context, CancellationToken cancellationToken) + => ValueTask.FromResult(input); + } +} + +public sealed class ParityHostFixture : WebApplicationFactory +{ + /// The same workflow as a document, node for node. + internal const string Document = """ + { + "dsl": "abacus.workflow/1.0", + "name": "parity-dsl", + "version": "1.0.0", + "context": { + "type": "object", + "required": ["orderId", "amount"], + "properties": { "orderId": { "type": "string" }, "amount": { "type": "number" } } + }, + "start": "price", + "output": ["settle"], + "nodes": [ + { "id": "price", "kind": "transform", + "set": { + "orderId": "$ctx.orderId", + "net": "$ctx.amount", + "vat": "$ctx.amount * 0.2", + "total": "$ctx.amount * 1.2" + } }, + { "id": "settle", "kind": "transform", + "set": { + "orderId": "$.orderId", "net": "$.net", "vat": "$.vat", "total": "$.total" + } } + ], + "edges": [ { "from": "price", "to": "settle" } ] + } + """; + + private readonly string _auditDatabasePath = + Path.Combine(Path.GetTempPath(), $"abacus-parity-{Guid.NewGuid():N}.db"); + + protected override IHost CreateHost(IHostBuilder builder) + { + builder.ConfigureServices(services => + { + var host = new WorkflowHostBuilder(services); + + host.AddWorkflow(new ParityWorkflow()) + .ConfigureDsl(registry => registry.EnforceEgress = false) + .AddDslWorkflowText(Document, "parity.workflow.json"); + }); + + return base.CreateHost(builder); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseSetting("Abacus:AuditRecords:ConnectionString", $"Data Source={_auditDatabasePath}"); + builder.ConfigureServices(services => + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("http://localhost"); + }).ConfigurePrimaryHttpMessageHandler(() => Server.CreateHandler()); + }); + } + + public T Resolve() where T : notnull => Services.GetRequiredService(); + + public async Task RunAsync(HttpClient client, string workflow, object context) + { + HttpResponseMessage response = await client.PostAsJsonAsync( + $"/workflows/{workflow}/instances", new { context }); + + response.EnsureSuccessStatusCode(); + + JsonElement body = await response.Content.ReadFromJsonAsync(); + string id = body.GetProperty("instanceId").GetString()!; + + var store = Resolve(); + DateTime deadline = DateTime.UtcNow.AddSeconds(20); + + while (DateTime.UtcNow < deadline) + { + WorkflowInstance? instance = await store.GetAsync(id, default); + + if (instance?.Status == InstanceStatus.Completed) + { + return instance.ResultJson is { Length: > 0 } json ? JsonNode.Parse(json) : null; + } + + if (instance is not null && instance.Status.IsTerminal()) + { + throw new InvalidOperationException( + $"'{workflow}' ended {instance.Status}: {instance.TerminalReason}"); + } + + await Task.Delay(25); + } + + throw new TimeoutException($"'{workflow}' did not complete."); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) return; + + try + { + if (File.Exists(_auditDatabasePath)) File.Delete(_auditDatabasePath); + } + catch (IOException) + { + } + } +} + +/// +/// The clearest statement that the DSL is a front end and not a fork: the same work, authored twice, +/// answering identically on the same host. +/// +public class DslParityTests : IClassFixture +{ + private readonly ParityHostFixture _fixture; + + public DslParityTests(ParityHostFixture fixture) => _fixture = fixture; + + [Theory] + [InlineData("ORD-1", 100)] + [InlineData("ORD-2", 0)] + [InlineData("ORD-3", 1)] + [InlineData("ORD-BIG", 999999)] + public async Task Both_front_ends_produce_the_same_result(string orderId, decimal amount) + { + using HttpClient client = _fixture.CreateClient(); + var context = new { orderId, amount }; + + JsonNode? compiled = await _fixture.RunAsync(client, "parity-compiled", context); + JsonNode? document = await _fixture.RunAsync(client, "parity-dsl", context); + + compiled.Should().NotBeNull(); + document.Should().NotBeNull(); + + foreach (string field in new[] { "orderId", "net", "vat", "total" }) + { + string compiledValue = Read(compiled!, field); + string documentValue = Read(document!, field); + + documentValue.Should().Be(compiledValue, + $"'{field}' should match; compiled={compiled!.ToJsonString()} document={document!.ToJsonString()}"); + } + } + + /// + /// Decimal arithmetic on both sides. 0.1 pricing is where a DSL that quietly used binary floating + /// point would diverge from a compiled workflow that did not. + /// + [Fact] + public async Task Arithmetic_agrees_on_a_value_binary_floating_point_would_not() + { + using HttpClient client = _fixture.CreateClient(); + var context = new { orderId = "ORD-DEC", amount = 0.1m }; + + JsonNode? compiled = await _fixture.RunAsync(client, "parity-compiled", context); + JsonNode? document = await _fixture.RunAsync(client, "parity-dsl", context); + + Read(document!, "vat").Should().Be(Read(compiled!, "vat")); + Read(document!, "total").Should().Be(Read(compiled!, "total")); + } + + [Fact] + public async Task Both_appear_in_the_catalog_side_by_side() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement catalog = await client.GetFromJsonAsync("/workflows"); + string[] names = [.. catalog.EnumerateArray().Select(w => w.GetProperty("name").GetString()!)]; + + names.Should().Contain("parity-compiled").And.Contain("parity-dsl"); + } + + /// Property casing differs between the two serializers; compare values, not spellings. + private static string Read(JsonNode node, string field) + { + foreach (string candidate in new[] { field, char.ToUpperInvariant(field[0]) + field[1..] }) + { + if (node[candidate] is { } value) + { + return value.GetValueKind() == JsonValueKind.Number + ? value.GetValue().ToString(System.Globalization.CultureInfo.InvariantCulture) + : value.ToJsonString().Trim('"'); + } + } + + throw new InvalidOperationException($"'{field}' is absent from {node.ToJsonString()}."); + } +} From bf3ba2ebcb1c1dfb895e27407a3f697d0b99f79b Mon Sep 17 00:00:00 2001 From: Ninja Date: Thu, 20 Aug 2026 22:09:32 +0100 Subject: [PATCH 7/8] feat(dsl): close the plan's remaining gaps and dead-stop interpretation failures Three items from 07-workflow-dsl-implementation-plan.md were unimplemented, and writing the tests for one of them found a defect. Catalog provenance (plan 4.3). GET /v2/workflows/{name} now reports source and documentHash. Abacus.Run cannot name the DSL, so a definition answers for itself through IDocumentAuthoredWorkflow and the catalog reports "compiled" with a null hash for anything that does not implement it. Same probe-by-is pattern the runtime already uses for INotifyingWorkflow. Architecture tests. Nothing enforced the DSL project's layering: it now asserts the DSL references Abacus.Run and neither the host nor infrastructure, holds no internals access, that the framework references neither the DSL nor JsonSchema.Net, and that the schema ships as exactly one embedded resource. Redaction and checkpoint size. Both were listed as tested mitigations and were not tested. Under a restrictive policy a node reads a secret from ctx, no event in the run carries it, and the instance's own state still does. A 128 KB context through a four-hop document shows the checkpoint holds about one context rather than one per hop. Defect: a custom node of the wrong executor shape was refused correctly and then retried, burning the whole attempt budget re-deriving an error that cannot change. Interpretation failures now throw DslInterpretationException and classify as a dead stop ahead of the document's own onFailure rules. Found because WrongShapeNodeFactory had been registered in the fixture but never exercised. Also covers egress enforcement at startup in both directions and compares the /dsl/* routes' authorization metadata against /workflows, so locking down the catalog forces the DSL routes to keep pace. 723 unit, 361 DSL, 232 integration, 7 chaos, all green. --- .../07-workflow-dsl-implementation-plan.md | 44 +++- .../Interpretation/DslBuiltInNodes.cs | 8 +- .../Interpretation/DslWorkflowDefinition.cs | 35 +++ .../Interpretation/IDslNodeFactory.cs | 21 ++ .../Abstractions/WorkflowDefinition.cs | 26 +++ src/Abacus.Run/Api/Endpoints.cs | 11 + .../DslInterpreterTests.cs | 58 +++++ .../ArchitectureBoundaryTests.cs | 86 +++++++ .../DslDocuments.cs | 94 +++++++- .../DslEnvelopeTests.cs | 218 ++++++++++++++++++ .../DslHostFixture.cs | 30 ++- .../DslHostingTests.cs | 96 ++++++++ .../DslParityTests.cs | 54 +++++ .../DslWorkflowTests.cs | 25 ++ 14 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs diff --git a/docs/implementation/07-workflow-dsl-implementation-plan.md b/docs/implementation/07-workflow-dsl-implementation-plan.md index 256954b..5665d68 100644 --- a/docs/implementation/07-workflow-dsl-implementation-plan.md +++ b/docs/implementation/07-workflow-dsl-implementation-plan.md @@ -16,8 +16,8 @@ workflow behaves; the DSL is a second front end onto the runtime that already ex ## Status -Phases 1–5 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, **209 integration** -(+59), **7 chaos**. +Phases 1–5 landed, complete against the plan as written. Suites green: **723 unit** (unchanged), +**361 DSL unit**, **232 integration** (+82), **7 chaos**. | Delivered | Where | | --------- | ----- | @@ -30,19 +30,29 @@ Phases 1–5 landed. Suites green: **723 unit** (unchanged), **358 DSL unit**, * | Deferred registration, directory loading, custom node catalog | [Hosting/](../../src/Abacus.Run.Dsl/Hosting/) | | `IContextValidatingWorkflow`, consulted after the type bind | [Core/WorkflowRegistry.cs](../../src/Abacus.Run/Core/WorkflowRegistry.cs) | | `ITemplateBindingSource` | [Executors/TemplateEngine.cs](../../src/Abacus.Run/Executors/TemplateEngine.cs) | +| `IDocumentAuthoredWorkflow`, so the catalog reports `source` and `documentHash` | [Abstractions/WorkflowDefinition.cs](../../src/Abacus.Run/Abstractions/WorkflowDefinition.cs), [Api/Endpoints.cs](../../src/Abacus.Run/Api/Endpoints.cs) | | `/dsl/schema`, `/dsl/nodes`, `/dsl/functions`, `/dsl/documents`, `/dsl/validate` | [Hosting/DslEndpoints.cs](../../src/Abacus.Run.Dsl/Hosting/DslEndpoints.cs) | +| Architecture boundary tests for the DSL project | [ArchitectureBoundaryTests.cs](../../tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs) | +| Redaction and large-context coverage | [DslEnvelopeTests.cs](../../tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs) | | Wiki chapter, README section, project-layout rows | [wiki.md](../wiki.md#authoring-with-the-dsl), [README.md](../../README.md) | | Shipped example document | [example-order.workflow.json](../../src/Abacus.Run.Service/Workflows/ExampleOrder/example-order.workflow.json) | ### Deviations from the plan as written -**The core change is two interfaces, not one.** `IContextValidatingWorkflow` was planned. +**The core change is three interfaces, not one.** `IContextValidatingWorkflow` was planned. + `ITemplateBindingSource` was not: `TemplateBindings` resolves dotted paths by reflection over a single root object, which cannot address an envelope carrying both a context and a payload. It is additive and opt-in — a type that does not implement it resolves exactly as before — and it is what lets `{{ $ctx.orderId }}` work inside the existing `ApiCallExecutor` and `LlmExecutor` rather than forking either. +`IDocumentAuthoredWorkflow` was not either, and is what §4.3's fourth row needed. The catalog cannot +report `source: "dsl"` by naming the DSL, because `Abacus.Run` does not reference it and must not. So +a definition answers for itself: `GET /v2/workflows/{name}` reports what the definition says, or +`"compiled"` with a null hash for one that says nothing. Same probe-by-`is` pattern the runtime +already uses for `INotifyingWorkflow`, and the framework still knows of no front end. + **Two grammar changes.** Unary `!` and `-` bind tightest, rather than sitting between `&&` and comparison as first written — `!has($.x) && …` is the common shape and standard precedence is what an author expects. And bare-identifier path roots are gone: every path starts `$`, `$ctx` or `$run`, @@ -88,6 +98,15 @@ across a serialization round trip. because it built its kind lookup with `ToDictionary`. Now built tolerantly, with robustness tests over pathological documents. +**A custom node of the wrong shape was retried.** A factory that hands back anything other than +`HostExecutor` is refused during the build, which was correct, but the +failure classified as retryable: the run burned its whole attempt budget re-deriving the same +message before stopping. Interpretation failures now throw `DslInterpretationException` and classify +as a dead stop ahead of the document's own `onFailure` rules — a document that cannot be interpreted +will not interpret on the next attempt either, and an author does not get a say in that one. Found by +writing the integration test for the shape check, which had been registered in the fixture but never +exercised. + ### Pre-existing issues found, not fixed here **`FanInExecutor` cannot work with `AddFanInBarrierEdge`.** It is declared @@ -102,8 +121,9 @@ node — and a compiled workflow using `DelayExecutor` — cannot run on a stock fixture registers an in-memory implementation; a deployable host has nothing. Phases 1–2 are independently testable with no host involved and carry most of the risk. Phase 3 is -mechanical once they land. Phase 4 is small — deliberately, because the design keeps the core change -to a single opt-in interface. +mechanical once they land. Phase 4 is small — deliberately, because every core change it needs is an +opt-in interface a definition may implement, and the three it added together come to a few dozen +lines of framework code. --- @@ -415,8 +435,8 @@ covers the common case in v1. | --- | --- | | The expression language grows into a scripting host | The function set is closed and small; extension goes through `custom` nodes, not new syntax. Adding a function is a deliberate change to a documented list. | | Diagnostics are unhelpful and the DSL is abandoned | Pointer accuracy is a tested requirement from Phase 2, not a polish item. Every diagnostic code has a test asserting its pointer. | -| The envelope's `ctx` inflates checkpoints | `Ctx` is a reference copy, cloned once at start. Measured in Phase 3 with a large-context fixture. | -| Redaction gaps — more data is in flight per message | DSL nodes traverse the same middleware pipeline. An integration test asserts a redacted field in `ctx` stays redacted at the last node. | +| The envelope's `ctx` inflates checkpoints | `Ctx` is a reference copy, cloned once at start. Measured: a 128 KB context through a four-hop document, asserting the checkpoint holds about one context rather than one per hop, and that the last superstep is no heavier than the first. | +| Redaction gaps — more data is in flight per message | DSL nodes traverse the same middleware pipeline. Measured under a restrictive policy: the node reads the secret, no event in the run carries it, and the instance's own state still does — so the run can still resume. | | Schema drift between published and embedded | One file, embedded from `docs/schema/`; a test asserts byte equality. | | The DSL looks like it can do anything and cannot | §11 of the design and the wiki chapter both state the boundary. `custom` is presented as the answer, not as an escape hatch. | @@ -426,9 +446,13 @@ covers the common case in v1. | Suite | Added | Covers | | --- | --- | --- | -| Unit | ~405 | AbEx, model, validation, interpreter, factories | -| Integration | ~70 | Startup, catalog, endpoints, end-to-end runs, redaction, parity with the compiled example | -| Architecture | 3 | `Abacus.Run.Dsl` depends only on the public surface; no host reference; schema resource matches the file | +| Unit | 361 | AbEx, model, validation, interpreter, factories, failure classification | +| Integration | 82 | Startup, egress enforcement, catalog and provenance, endpoints and their authorization, end-to-end runs of every node kind, redaction, checkpoint size, parity | +| Architecture | 4 | `Abacus.Run.Dsl` depends only on the public surface; no host or infrastructure reference; the framework does not reference the DSL; the schema ships as one embedded resource | + +The DSL suite is its own project, so those 361 are not part of the 723 the framework already had. +Byte equality between the embedded schema and the published file is asserted in the DSL suite, where +the resource is readable; the architecture test asserts there is exactly one such resource. `dotnet build Abacus.Run.slnx` then `dotnet test`, with the existing suites unchanged — the `IContextValidatingWorkflow` hook is the only core edit, and nothing implements it today. diff --git a/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs index 9cee0a5..1263023 100644 --- a/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs +++ b/src/Abacus.Run.Dsl/Interpretation/DslBuiltInNodes.cs @@ -399,20 +399,20 @@ private static IHostExecutor Custom( { // Validation refuses this at registration, so reaching here means the catalog changed // underneath a document that was already accepted. - throw new InvalidOperationException( + throw new DslInterpretationException( $"Node '{node.Id}' names custom node '{node.NodeName}', which is not registered. " + $"Known: {(catalog.Names.Count == 0 ? "(none)" : string.Join(", ", catalog.Names))}."); } IHostExecutor executor = factory.Create(context) - ?? throw new InvalidOperationException( + ?? throw new DslInterpretationException( $"The factory for custom node '{node.NodeName}' returned null for node '{node.Id}'."); if (executor.InputType != typeof(DslMessage) || executor.OutputType != typeof(DslMessage)) { // Every edge in a DSL graph carries the envelope. A node emitting anything else breaks // the next edge rather than its own, so it is refused where the mistake was made. - throw new InvalidOperationException( + throw new DslInterpretationException( $"Custom node '{node.NodeName}' produced an executor of " + $"{executor.InputType.Name} -> {executor.OutputType.Name}. " + $"A DSL node must be HostExecutor<{nameof(DslMessage)}, {nameof(DslMessage)}>."); @@ -420,7 +420,7 @@ private static IHostExecutor Custom( if (!string.Equals(executor.Id, node.Id, StringComparison.Ordinal)) { - throw new InvalidOperationException( + throw new DslInterpretationException( $"Custom node '{node.NodeName}' produced an executor with id '{executor.Id}', " + $"but the document declared '{node.Id}'. Gate policy and node state key off the " + "declared id, so they must match."); diff --git a/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs b/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs index 0c50eb1..1c7e6a0 100644 --- a/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs +++ b/src/Abacus.Run.Dsl/Interpretation/DslWorkflowDefinition.cs @@ -29,6 +29,7 @@ namespace Abacus.Run.Dsl.Interpretation; public class DslWorkflowDefinition : IWorkflowDefinition, IContextValidatingWorkflow, + IDocumentAuthoredWorkflow, INotifyingWorkflow, IDomainEventTriggeredWorkflow { @@ -72,6 +73,9 @@ public static DslWorkflowDefinition Create( /// The canonical hash of the source document. Identity, and drift detection. public string DocumentHash => Document.Hash; + /// Names this front end to the catalog, which cannot name it itself. + public string Source => "dsl"; + public NotificationPolicy Notifications => _notifications; public IReadOnlyList Triggers => _triggers; @@ -350,6 +354,14 @@ public FailureDisposition Classify(WorkflowFailure failure) { ArgumentNullException.ThrowIfNull(failure); + // Ahead of the document's own rules, and deliberately not overridable by them: a document + // that cannot be interpreted will not interpret on the next attempt either, so retrying + // spends the attempt budget to arrive at the same message. The run should stop and say so. + if (Contains(failure.Exception)) + { + return FailureDisposition.DeadStop; + } + foreach (DslFailureRule rule in Document.OnFailure.Where(r => Matches(r, failure))) { return rule.Disposition switch @@ -363,6 +375,29 @@ public FailureDisposition Classify(WorkflowFailure failure) return DefaultFailureClassifier.Instance.Classify(failure); } + /// + /// Walks the chain, because a build failure reaches the classifier wrapped — the engine surfaces + /// it through whatever the graph construction threw it into. + /// + private static bool Contains(Exception? exception) where T : Exception + { + for (Exception? current = exception; current is not null; current = current.InnerException) + { + if (current is T) + { + return true; + } + + if (current is AggregateException aggregate && + aggregate.InnerExceptions.Any(Contains)) + { + return true; + } + } + + return false; + } + private static bool Matches(DslFailureRule rule, WorkflowFailure failure) { if (rule.Node is { Length: > 0 } node && diff --git a/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs b/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs index 358b691..fec634e 100644 --- a/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs +++ b/src/Abacus.Run.Dsl/Interpretation/IDslNodeFactory.cs @@ -4,6 +4,27 @@ namespace Abacus.Run.Dsl.Interpretation; +/// +/// A document that validated but cannot be turned into a graph. +/// +/// +/// Distinct from DslValidationException because it is thrown during a build rather than at +/// registration, and distinct from an ordinary node failure because it is deterministic: the +/// interpretation that failed on this attempt will fail identically on the next one. Classified as a +/// dead stop for exactly that reason — retrying it only burns attempts before reporting the same +/// message. +/// +public sealed class DslInterpretationException : Exception +{ + public DslInterpretationException(string message) : base(message) + { + } + + public DslInterpretationException(string message, Exception inner) : base(message, inner) + { + } +} + /// What a factory is given when a document asks for one of its nodes. public sealed record DslNodeContext( DslNode Node, diff --git a/src/Abacus.Run/Abstractions/WorkflowDefinition.cs b/src/Abacus.Run/Abstractions/WorkflowDefinition.cs index e2930c6..a71ab67 100644 --- a/src/Abacus.Run/Abstractions/WorkflowDefinition.cs +++ b/src/Abacus.Run/Abstractions/WorkflowDefinition.cs @@ -27,6 +27,32 @@ FailureDisposition IWorkflowDefinition.Classify(WorkflowFailure failure) => DefaultFailureClassifier.Instance.Classify(failure); } +/// +/// Implemented by a definition that was authored somewhere other than C#, so the catalog can say +/// where a workflow came from and which revision of that source is running. +/// +/// +/// +/// The framework cannot name the DSL — Abacus.Run does not reference it, and must not. So the +/// front end answers for itself, and the catalog reports compiled for any definition that does +/// not implement this. That keeps provenance discoverable without the framework knowing what front +/// ends exist. +/// +/// +/// is what makes two hosts comparable: same name, same version and same +/// hash is the same workflow, and a differing hash for a published version is a deployment fault +/// rather than a curiosity. +/// +/// +public interface IDocumentAuthoredWorkflow +{ + /// The front end that produced this definition, lowercase — for example dsl. + string Source { get; } + + /// The canonical hash of the source document. + string DocumentHash { get; } +} + /// /// One executor node of a workflow graph as the definition declared it. Produced during a build and /// surfaced by the catalog API so a tenant can see what there is to configure. diff --git a/src/Abacus.Run/Api/Endpoints.cs b/src/Abacus.Run/Api/Endpoints.cs index 54294f4..63ce225 100644 --- a/src/Abacus.Run/Api/Endpoints.cs +++ b/src/Abacus.Run/Api/Endpoints.cs @@ -165,6 +165,17 @@ private static void MapCatalog(IEndpointRouteBuilder app) contextType = v.ContextType.Name, resultType = v.ResultType.Name, + // Where this version was authored. "compiled" is the answer for a C# + // definition; a document-authored one names its own front end and carries + // the hash of the document that produced it, so a deployment can be checked + // against the revision it was meant to be running. + source = v.Definition is IDocumentAuthoredWorkflow authored + ? authored.Source + : "compiled", + documentHash = v.Definition is IDocumentAuthoredWorkflow hashed + ? hashed.DocumentHash + : null, + // What a subscriber may expect from this workflow beyond the framework's own // events — discoverable, the way node descriptors already advertise gates. notifications = v.Definition is INotifyingWorkflow notifying diff --git a/tests/Abacus.Run.DslTests/DslInterpreterTests.cs b/tests/Abacus.Run.DslTests/DslInterpreterTests.cs index b3fdcc6..4029e8a 100644 --- a/tests/Abacus.Run.DslTests/DslInterpreterTests.cs +++ b/tests/Abacus.Run.DslTests/DslInterpreterTests.cs @@ -350,6 +350,64 @@ public void A_rule_scoped_to_a_node_does_not_match_another() .Should().NotBe(FailureDisposition.Escalate); } + /// + /// A document that cannot be interpreted will not interpret on the next attempt either. Retrying + /// it spends the attempt budget to arrive at the same message, so it stops. + /// + [Fact] + public void An_interpretation_failure_dead_stops() + { + DslWorkflowDefinition definition = Build(DslFixtures.MinimalText); + + definition.Classify(Failure(new DslInterpretationException("wrong shape"))) + .Should().Be(FailureDisposition.DeadStop); + } + + /// + /// A build failure arrives wrapped in whatever the graph construction threw it into, so the + /// classifier walks the chain rather than testing the outermost type. + /// + [Fact] + public void A_wrapped_interpretation_failure_dead_stops_too() + { + DslWorkflowDefinition definition = Build(DslFixtures.MinimalText); + + definition.Classify(Failure(new InvalidOperationException("build failed", + new DslInterpretationException("wrong shape")))) + .Should().Be(FailureDisposition.DeadStop); + + definition.Classify(Failure(new AggregateException( + new DslInterpretationException("wrong shape")))) + .Should().Be(FailureDisposition.DeadStop); + } + + /// + /// And not overridable by the document, which is the one classification rule an author does not + /// get a say in. The schema does not even offer DslInterpretationException as a matchable + /// name, so the way a document could reach it is a rule matched on something else — here the node + /// the failure came from. + /// + [Fact] + public void A_documents_retry_rule_cannot_reopen_an_interpretation_failure() + { + string text = DslFixtures.Broken(d => d["onFailure"] = new JsonArray( + new JsonObject + { + ["match"] = new JsonObject { ["node"] = "a" }, + ["disposition"] = "retry" + })); + + DslWorkflowDefinition definition = Build(text); + + definition.Classify(Failure(new DslInterpretationException("wrong shape"), "a")) + .Should().Be(FailureDisposition.DeadStop); + + // The rule still governs everything else from that node, so the guard is narrow rather than + // a blanket override of the document. + definition.Classify(Failure(new InvalidOperationException("something else"), "a")) + .Should().Be(FailureDisposition.Retry); + } + /// /// A document should only have to state where it disagrees; the framework already knows a rate /// limit is worth retrying and a validation error is not. diff --git a/tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs b/tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs index d648148..b735f05 100644 --- a/tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/tests/Abacus.Run.IntegrationTests/ArchitectureBoundaryTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using Abacus.Adapters.Messaging.RabbitMQ; using Abacus.Adapters.Cache.Redis; using Abacus.Run.Abstractions; @@ -26,6 +27,7 @@ public class ArchitectureBoundaryTests private static readonly Assembly Host = typeof(AbacusServiceCollectionExtensions).Assembly; private static readonly Assembly RedisAdapter = typeof(RedisNotificationBus).Assembly; private static readonly Assembly RabbitMqAdapter = typeof(RabbitMqDomainEventBroker).Assembly; + private static readonly Assembly Dsl = typeof(Abacus.Run.Dsl.Interpretation.DslMessage).Assembly; [Fact] public void The_library_and_the_host_are_separate_assemblies() @@ -267,6 +269,90 @@ public void The_host_defines_no_framework_extension_points_outside_declared_work "extension points outside a declared workflow namespace would mean the shell had grown behaviour of its own"); } + /// + /// The DSL is a second front end onto the framework, not a layer inside it. It sits where an + /// adapter sits: above Abacus.Run, below any deployment, and knowing about neither the + /// host nor infrastructure. + /// + [Fact] + public void The_dsl_is_a_front_end_on_the_framework_and_nothing_else_of_ours() + { + Dsl.GetName().Name.Should().Be("Abacus.Run.Dsl"); + + string[] references = [.. Dsl.GetReferencedAssemblies().Select(a => a.Name!)]; + + references.Should().Contain("Abacus.Run", + "the DSL exists to interpret documents onto the framework's runtime"); + + references.Should().NotContain("Abacus.Run.Service", + "a front end that referenced a host would be tied to one deployment"); + + foreach (string forbidden in new[] + { + "Abacus.Adapters.Cache.Redis", "Abacus.Adapters.Messaging.RabbitMQ", + "Microsoft.EntityFrameworkCore", "StackExchange.Redis", "RabbitMQ.Client" + }) + { + references.Should().NotContain(forbidden, + "the DSL declares what runs, never where it is stored or how it is transported"); + } + } + + /// + /// The DSL reaches the framework through the same surface any consumer has. If it needed + /// internals, the seams the design leans on — IContextValidatingWorkflow, + /// ITemplateBindingSource, IDocumentAuthoredWorkflow — would be missing something, + /// and the next front end would have to be written inside Abacus.Run to work at all. + /// + [Fact] + public void The_dsl_uses_only_the_frameworks_public_surface() + { + string[] granted = Library.GetCustomAttributes() + .Select(a => a.AssemblyName.Split(',')[0]) + .ToArray(); + + granted.Should().NotContain("Abacus.Run.Dsl", + "a front end with internals access is a front end whose seams are not really public"); + + // The seams themselves, named: each is public, so a third front end has the same reach. + typeof(IContextValidatingWorkflow).IsPublic.Should().BeTrue(); + typeof(IDocumentAuthoredWorkflow).IsPublic.Should().BeTrue(); + typeof(Abacus.Run.Executors.ITemplateBindingSource).IsPublic.Should().BeTrue(); + typeof(IContextValidatingWorkflow).Assembly.Should().BeSameAs(Library); + typeof(IDocumentAuthoredWorkflow).Assembly.Should().BeSameAs(Library); + } + + /// + /// The framework must not know the DSL exists. It reports provenance through an interface it + /// declares and the DSL implements, so Abacus.Run names no front end and a host that + /// authors every workflow in C# carries neither the schema validator nor the expression parser. + /// + [Fact] + public void The_framework_does_not_reference_the_dsl() + { + Library.GetReferencedAssemblies().Select(a => a.Name) + .Should().NotContain("Abacus.Run.Dsl"); + + Library.GetReferencedAssemblies().Select(a => a.Name) + .Should().NotContain("JsonSchema.Net", + "the schema validator is the DSL's cost to carry, not every consumer's"); + } + + /// + /// The schema ships inside the DSL assembly as one embedded resource. Byte equality with the + /// published file is asserted in the DSL suite; what belongs here is that there is exactly one + /// copy and it travels with the code that enforces it. + /// + [Fact] + public void The_dsl_carries_the_schema_as_an_embedded_resource() + { + string[] schemaResources = [.. Dsl.GetManifestResourceNames() + .Where(n => n.Contains("workflow-dsl", StringComparison.Ordinal))]; + + schemaResources.Should().ContainSingle("one copy, or the published and enforced schemas can drift") + .Which.Should().EndWith(".json"); + } + [Fact] public void The_host_runs_on_the_library_defaults_when_no_infrastructure_is_configured() { diff --git a/tests/Abacus.Run.IntegrationTests/DslDocuments.cs b/tests/Abacus.Run.IntegrationTests/DslDocuments.cs index ac12124..edea22b 100644 --- a/tests/Abacus.Run.IntegrationTests/DslDocuments.cs +++ b/tests/Abacus.Run.IntegrationTests/DslDocuments.cs @@ -349,6 +349,95 @@ internal static class DslDocuments } """; + /// + /// Carries a secret in the start context, reads it in a node, and puts it in a notification + /// payload. The node must see the real value; anything that leaves the process must not. + /// + internal const string Secret = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-secret", + "version": "1.0.0", + "start": "handle", + "output": ["handle"], + "nodes": [ + { "id": "handle", "kind": "transform", + "set": { + "order": "$ctx.orderId", + "card": "$ctx.cardNumber", + "cardLength": "len($ctx.cardNumber)" + }, + "notify": { + "name": "handled", + "payload": { "order": "$ctx.orderId", "card": "$ctx.cardNumber" } + } } + ], + "edges": [], + "notifications": { "level": "standard", "stream": true } + } + """; + + /// + /// Four hops, each reading the context. Run with a large context it answers the question the + /// envelope raises: does carrying ctx through every node grow the checkpoint per hop? + /// + internal const string Wide = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-wide", + "version": "1.0.0", + "start": "one", + "output": ["four"], + "nodes": [ + { "id": "one", "kind": "transform", "set": { "hop": "1", "size": "len($ctx.notes)" } }, + { "id": "two", "kind": "transform", "set": { "hop": "2", "size": "$.size" } }, + { "id": "three", "kind": "transform", "set": { "hop": "3", "size": "$.size" } }, + { "id": "four", "kind": "transform", + "set": { "hop": "4", "size": "$.size", "order": "$ctx.orderId", "seen": "len($ctx.notes)" } } + ], + "edges": [ + { "from": "one", "to": "two" }, + { "from": "two", "to": "three" }, + { "from": "three", "to": "four" } + ] + } + """; + + /// + /// Names a registered factory that hands back the wrong executor shape. It registers — the + /// catalog knows the name and the parameters check out — and fails when it is built, which is the + /// only moment the shape exists to be checked. + /// + internal const string WrongShape = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-wrong-shape", + "version": "1.0.0", + "start": "seed", + "output": ["broken"], + "nodes": [ + { "id": "seed", "kind": "transform", "set": { "value": "$ctx.amount" } }, + { "id": "broken", "kind": "custom", "node": "wrong-shape" } + ], + "edges": [ { "from": "seed", "to": "broken" } ] + } + """; + + /// An http node with no allow-list, for a host that enforces egress. + internal const string UnrestrictedHttp = """ + { + "dsl": "abacus.workflow/1.0", + "name": "dsl-open-egress", + "version": "1.0.0", + "start": "call", + "output": ["call"], + "nodes": [ + { "id": "call", "kind": "http", "url": "https://anywhere.example/v1/things" } + ], + "edges": [] + } + """; + internal static IReadOnlyList<(string Name, string Text)> All => [ ("linear", Linear), @@ -367,6 +456,9 @@ internal static class DslDocuments ("failing", Failing), ("audited", Audited), ("triggered", Triggered), - ("selective-fan-out", SelectiveFanOut) + ("selective-fan-out", SelectiveFanOut), + ("secret", Secret), + ("wide", Wide), + ("wrong-shape", WrongShape) ]; } diff --git a/tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs b/tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs new file mode 100644 index 0000000..2ddfb7b --- /dev/null +++ b/tests/Abacus.Run.IntegrationTests/DslEnvelopeTests.cs @@ -0,0 +1,218 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Abacus.Run.Abstractions; +using Abacus.Run.Core; +using Abacus.Run.Persistence; +using FluentAssertions; +using Xunit; + +namespace Abacus.Run.IntegrationTests; + +/// +/// The two things the envelope is charged with getting right. It carries the whole start context to +/// every node, which is what makes the DSL usable — and is also two liabilities: more data in flight +/// per message, and a context that could be copied per hop. Both are asserted here against the real +/// host rather than argued about in the design doc. +/// +public class DslRedactionTests : IClassFixture +{ + private const string Card = "4111111111111111"; + + private readonly DslRedactedHostFixture _fixture; + + public DslRedactionTests(DslRedactedHostFixture fixture) => _fixture = fixture; + + [Fact] + public void The_restrictive_policy_is_the_one_the_host_is_running() + => _fixture.Resolve().Should().BeSameAs(_fixture.Policy, + "every assertion in this class is vacuous under the permissive default"); + + /// + /// The node reads the secret — it has to, that is the point of the envelope — and the event that + /// records what the node did does not carry it. Redaction is applied at write time, so the + /// history API cannot leak what the live stream withheld. + /// + [Fact] + public async Task A_secret_in_the_context_reaches_the_node_but_not_the_event_log() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-secret", + new { orderId = "ORD-SECRET", amount = 10m, cardNumber = Card }); + + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + result!["card"]!.GetValue().Should().Be(Card, "the node is entitled to the value"); + result["cardLength"]!.GetValue().Should().Be(Card.Length, "and to compute over it"); + + JsonNode payload = await CustomPayloadAsync(client, id, "custom.handled"); + + payload["card"]!.GetValue().Should().Be(RedactionPolicy.Mask, + "the card is not on the allow-list, so nothing that leaves the process may carry it"); + payload["order"]!.GetValue().Should().Be("ORD-SECRET", + "an allow-listed field still arrives, or the policy would be a mute button"); + } + + /// + /// The sweep, not the sample. A DSL run emits lifecycle events, node events and the workflow's own + /// notification, and the secret must be in none of them — including anywhere the envelope was + /// serialized wholesale rather than field by field. + /// + [Fact] + public async Task No_event_in_the_whole_run_carries_the_secret() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-secret", + new { orderId = "ORD-SWEEP", amount = 10m, cardNumber = Card }); + + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonElement history = await client.GetFromJsonAsync($"/instances/{id}/events/history"); + JsonElement[] items = [.. history.GetProperty("items").EnumerateArray()]; + + items.Should().NotBeEmpty("a completed run always logged something"); + + foreach (JsonElement item in items) + { + item.GetRawText().Should().NotContain(Card, + $"'{Read(item, "eventType")}' leaked the card number"); + } + } + + /// + /// The instance store is inside the trust boundary and is not redacted — the run has to be able to + /// resume from its own state. Asserted so the previous test cannot be satisfied by a change that + /// masks the workflow's data everywhere, which would break resumption instead of protecting it. + /// + [Fact] + public async Task The_instance_state_still_carries_what_the_run_needs() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-secret", + new { orderId = "ORD-STATE", amount = 10m, cardNumber = Card }); + + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + WorkflowInstance instance = (await _fixture.Resolve().GetAsync(id, default))!; + instance.ContextJson.Should().Contain(Card, "the context is the run's own input"); + } + + private static async Task CustomPayloadAsync(HttpClient client, string id, string eventType) + { + JsonElement history = await client.GetFromJsonAsync($"/instances/{id}/events/history"); + + JsonElement[] matching = [.. history.GetProperty("items").EnumerateArray() + .Where(e => Read(e, "eventType") == eventType)]; + + matching.Should().ContainSingle( + "the event log held: " + string.Join(", ", history.GetProperty("items").EnumerateArray() + .Select(e => Read(e, "eventType")))); + + JsonElement raw = matching[0].GetProperty("payloadJson"); + + return (raw.ValueKind == JsonValueKind.String + ? JsonNode.Parse(raw.GetString()!) + : JsonNode.Parse(raw.GetRawText()))!; + } + + private static string Read(JsonElement item, string property) + => item.TryGetProperty(property, out JsonElement value) ? value.GetString() ?? "?" : "?"; +} + +/// +/// The envelope's cost. Ctx is cloned once at start and reference-copied after, so a run's +/// checkpoint should be about the size of its context however many nodes it passes through. A +/// regression here — a deep clone per hop, or an envelope that accumulates — shows up as growth. +/// +public class DslLargeContextTests : IClassFixture +{ + private const int ContextBytes = 128 * 1024; + + private readonly DslHostFixture _fixture; + + public DslLargeContextTests(DslHostFixture fixture) => _fixture = fixture; + + private static object LargeContext(string orderId) => new + { + orderId, + amount = 1m, + notes = new string('n', ContextBytes) + }; + + [Fact] + public async Task A_large_context_survives_every_hop_intact() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-wide", LargeContext("ORD-LARGE")); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + JsonNode? result = await _fixture.ResultOfAsync(id); + + result!["hop"]!.GetValue().Should().Be(4); + result["order"]!.GetValue().Should().Be("ORD-LARGE"); + result["seen"]!.GetValue().Should().Be(ContextBytes, + "the fourth node read the context, not a truncated copy of it"); + result["size"]!.GetValue().Should().Be(ContextBytes, + "and the value the first node measured travelled with the envelope"); + } + + /// + /// One copy in flight, not one per node visited. The bound is generous — serialization overhead, + /// the envelope's own fields and the framework's queue all sit inside it — but it is a small + /// multiple of the context rather than a multiple of the hop count, which is the claim. + /// + [Fact] + public async Task The_checkpoint_does_not_grow_per_hop() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-wide", LargeContext("ORD-CHECKPOINT")); + await _fixture.WaitForStatusAsync(id, InstanceStatus.Completed); + + IReadOnlyList checkpoints = + _fixture.Resolve().Describe(id); + + checkpoints.Should().NotBeEmpty("the engine checkpoints each superstep"); + + int[] sizes = [.. checkpoints.Select(c => c.SizeBytes)]; + + sizes.Max().Should().BeLessThan(ContextBytes * 4, + $"a checkpoint should hold about one context, not one per hop; sizes were {string.Join(", ", sizes)}"); + + // The shape that matters: the last superstep is no heavier than the first. Growth here means + // the envelope started accumulating something. + sizes[^1].Should().BeLessThan((int)(sizes[0] * 1.5), + $"checkpoints grew across the run: {string.Join(", ", sizes)}"); + } + + /// + /// The same run with a small context, to show the size above is the context's and not the + /// framework's. Without this the bound could be met by a checkpoint that ignored the context + /// entirely — which would mean the run could not resume. + /// + [Fact] + public async Task A_checkpoint_is_the_size_of_its_context() + { + using HttpClient client = _fixture.CreateClient(); + + string small = await _fixture.StartAsync(client, "dsl-wide", + new { orderId = "ORD-SMALL", amount = 1m, notes = "n" }); + await _fixture.WaitForStatusAsync(small, InstanceStatus.Completed); + + string large = await _fixture.StartAsync(client, "dsl-wide", LargeContext("ORD-BIG")); + await _fixture.WaitForStatusAsync(large, InstanceStatus.Completed); + + var store = _fixture.Resolve(); + + int smallest = store.Describe(small).Max(c => c.SizeBytes); + int biggest = store.Describe(large).Max(c => c.SizeBytes); + + biggest.Should().BeGreaterThan(smallest + ContextBytes / 2, + "the context is what a checkpoint is mostly made of"); + } +} diff --git a/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs b/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs index c5d6e68..0cbe717 100644 --- a/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs +++ b/tests/Abacus.Run.IntegrationTests/DslHostFixture.cs @@ -157,7 +157,7 @@ protected override ValueTask ExecuteCoreAsync( /// A host with only DSL workflows registered, so a failure is unambiguously about the DSL rather /// than about a compiled definition sitting beside it. /// -public sealed class DslHostFixture : WebApplicationFactory +public class DslHostFixture : WebApplicationFactory { public StubHttpHandler Http { get; } = new(); public StubChatClient Chat { get; } = new(); @@ -166,6 +166,12 @@ public sealed class DslHostFixture : WebApplicationFactory private readonly string _auditDatabasePath = Path.Combine(Path.GetTempPath(), $"abacus-dsl-audit-{Guid.NewGuid():N}.db"); + /// + /// The redaction policy this host runs under, or null for the framework default. Overridden by + /// the fixture that exists to prove a DSL message is redacted like any other. + /// + protected virtual IRedactionPolicy? Redaction => null; + protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureServices(services => @@ -173,6 +179,13 @@ protected override IHost CreateHost(IHostBuilder builder) services.AddSingleton(Timers); services.AddSingleton(Chat); + if (Redaction is { } redaction) + { + // Registered last so it wins over the framework's TryAdd default, whichever order + // the host builder happens to run its callbacks in. + services.AddSingleton(redaction); + } + // The DSL's http node resolves this named client, so a stub handler here reaches every // http node without any of them knowing they are under test. services.AddHttpClient(ApiCallOptions.HttpClientName) @@ -285,3 +298,18 @@ protected override void Dispose(bool disposing) } } } + +/// +/// The same host under a restrictive redaction policy: every body field is masked except the few +/// named here. A DSL envelope carries more per message than a compiled one — the whole start context +/// travels with every hop — so this fixture exists to prove that extra reach does not widen what +/// leaves the process. +/// +public sealed class DslRedactedHostFixture : DslHostFixture +{ + public RedactionPolicy Policy { get; } = new( + bodyAllowList: ["order", "workflow", "version", "attempt", "resumed", "status", "executorId"], + allowAllBodyFields: false); + + protected override IRedactionPolicy? Redaction => Policy; +} diff --git a/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs index 5aff628..277d910 100644 --- a/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs +++ b/tests/Abacus.Run.IntegrationTests/DslHostingTests.cs @@ -10,6 +10,9 @@ using Abacus.Run.Dsl.Interpretation; using Abacus.Run.Dsl.Validation; using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -90,6 +93,68 @@ public void An_unregistered_custom_node_fails_startup() .Which.Message.Should().Contain(DslCodes.UnknownCustomNode); } + /// + /// Egress enforcement is on by default, and the document is where the allow-list has to be + /// declared — a host that restricts outbound calls cannot have that decision made for it by + /// whoever wrote the JSON. + /// + [Fact] + public void An_http_node_without_an_allow_list_fails_startup_when_egress_is_enforced() + { + using ServiceProvider provider = Build(host => host + .AddDslWorkflowText(DslDocuments.UnrestrictedHttp, "open.json")); + + Action act = () => provider.GetServices().ToArray(); + + act.Should().Throw() + .Which.Message.Should().Contain(DslCodes.EgressHostsRequired); + } + + [Fact] + public void An_http_node_that_declares_its_hosts_registers_under_enforcement() + { + using ServiceProvider provider = Build(host => host + .AddDslWorkflowText(DslDocuments.Http, "http.json")); + + Action act = () => provider.GetServices().ToArray(); + act.Should().NotThrow("the document names the host it calls"); + } + + /// + /// A host that does not police egress must not have the rule invented for it either. The setting + /// is the host's to make, which is why the check is reported as skipped rather than passed when + /// there is no environment to ask. + /// + [Fact] + public void The_same_document_registers_when_the_host_does_not_enforce_egress() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.UnrestrictedHttp, "open.json")); + + Action act = () => provider.GetServices().ToArray(); + act.Should().NotThrow(); + } + + /// + /// The registered definition reports its own provenance, which is how the catalog can say + /// "dsl" without Abacus.Run knowing the DSL exists. + /// + [Fact] + public void A_registered_document_reports_its_source_and_hash() + { + using ServiceProvider provider = Build(host => host + .ConfigureDsl(r => r.EnforceEgress = false) + .AddDslWorkflowText(DslDocuments.Linear, "linear")); + + IWorkflowDefinition definition = provider.GetServices().Single(); + + var authored = definition.Should().BeAssignableTo().Subject; + authored.Source.Should().Be("dsl"); + authored.DocumentHash.Should().Be( + DslCanonicalHash.Compute(JsonNode.Parse(DslDocuments.Linear)!)); + } + /// A published version is immutable, and two documents claiming one must not both win. [Fact] public void Two_documents_claiming_one_version_fail_startup() @@ -419,4 +484,35 @@ public async Task A_root_level_diagnostic_reports_a_usable_pointer() result.GetProperty("diagnostics").EnumerateArray().First() .GetProperty("pointer").GetString().Should().Be("/"); } + + /// + /// Validating a document reflects the host's registered node names back to the caller — not + /// secret, but not anonymous either. The DSL routes must therefore be exactly as protected as the + /// catalog they describe, so this compares their authorization metadata rather than asserting a + /// particular policy: whatever the catalog requires today, the DSL routes require too. + /// + [Theory] + [InlineData("/dsl/validate")] + [InlineData("/dsl/nodes")] + [InlineData("/dsl/documents")] + [InlineData("/dsl/schema")] + [InlineData("/dsl/functions")] + public void The_dsl_routes_carry_the_same_authorization_as_the_catalog(string route) + { + RouteEndpoint[] endpoints = [.. _fixture.Resolve().Endpoints.OfType()]; + + RouteEndpoint catalog = endpoints.Single(e => e.RoutePattern.RawText == "/workflows"); + RouteEndpoint dsl = endpoints.Single(e => e.RoutePattern.RawText == route); + + Describe(dsl).Should().BeEquivalentTo(Describe(catalog), + $"'{route}' is a control-plane route like any other"); + + static (bool Anonymous, string[] Policies) Describe(Endpoint endpoint) => + ( + endpoint.Metadata.OfType().Any(), + [.. endpoint.Metadata.OfType() + .Select(a => $"{a.Policy}|{a.Roles}|{a.AuthenticationSchemes}") + .Order(StringComparer.Ordinal)] + ); + } } diff --git a/tests/Abacus.Run.IntegrationTests/DslParityTests.cs b/tests/Abacus.Run.IntegrationTests/DslParityTests.cs index dc4da5f..f4c453b 100644 --- a/tests/Abacus.Run.IntegrationTests/DslParityTests.cs +++ b/tests/Abacus.Run.IntegrationTests/DslParityTests.cs @@ -5,6 +5,7 @@ using Abacus.Run.Api; using Abacus.Run.Core; using Abacus.Run.Dsl.Hosting; +using Abacus.Run.Dsl.Interpretation; using FluentAssertions; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -233,6 +234,59 @@ public async Task Both_appear_in_the_catalog_side_by_side() names.Should().Contain("parity-compiled").And.Contain("parity-dsl"); } + /// + /// Side by side is not the same as indistinguishable. An operator looking at a running host needs + /// to know which of two workflows came from a document, and this is the only fixture where both + /// answers are available from one catalog. + /// + [Fact] + public async Task The_catalog_says_which_front_end_authored_each_version() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement compiled = await client.GetFromJsonAsync("/workflows/parity-compiled"); + JsonElement version = compiled.GetProperty("versions").EnumerateArray().Single(); + + version.GetProperty("source").GetString().Should().Be("compiled"); + version.GetProperty("documentHash").ValueKind.Should().Be(JsonValueKind.Null, + "a C# definition has no document to hash"); + + JsonElement document = await client.GetFromJsonAsync("/workflows/parity-dsl"); + JsonElement dslVersion = document.GetProperty("versions").EnumerateArray().Single(); + + dslVersion.GetProperty("source").GetString().Should().Be("dsl"); + dslVersion.GetProperty("documentHash").GetString().Should().NotBeNullOrWhiteSpace(); + } + + /// + /// The hash the catalog reports must be the hash of the document that was registered — the same + /// value the DSL's own route reports, and the same value again after a restart of the same + /// composition. A hash that only agreed with itself would detect no drift at all. + /// + [Fact] + public async Task The_catalog_hash_is_the_registered_documents_hash() + { + using HttpClient client = _fixture.CreateClient(); + + JsonElement documents = await client.GetFromJsonAsync("/dsl/documents"); + string expected = documents.EnumerateArray() + .Single(d => d.GetProperty("name").GetString() == "parity-dsl") + .GetProperty("documentHash").GetString()!; + + JsonElement workflow = await client.GetFromJsonAsync("/workflows/parity-dsl"); + + workflow.GetProperty("versions").EnumerateArray().Single() + .GetProperty("documentHash").GetString().Should().Be(expected); + + // Deterministic across processes, not merely within one: the point of the hash is that two + // hosts can be compared. + DslWorkflowDefinition registered = _fixture.Resolve().Find("parity-dsl")!; + registered.DocumentHash.Should().Be(expected); + Abacus.Run.Dsl.Validation.DslCanonicalHash + .Compute(JsonNode.Parse(ParityHostFixture.Document)!) + .Should().Be(expected, "the hash is of the document text, computable without a host"); + } + /// Property casing differs between the two serializers; compare values, not spellings. private static string Read(JsonNode node, string field) { diff --git a/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs b/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs index 45d72e4..8e9df1c 100644 --- a/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs +++ b/tests/Abacus.Run.IntegrationTests/DslWorkflowTests.cs @@ -391,6 +391,31 @@ public async Task A_registered_custom_node_runs_with_its_parameters() result!["value"]!.GetValue().Should().Be(21m, "times was 3"); } + /// + /// Every edge in a DSL graph carries the envelope, so a factory that returns any other shape is + /// refused where the mistake was made rather than breaking the next edge. Validation cannot catch + /// it — the shape does not exist until the factory is asked — so the build must, and the run must + /// end saying so. + /// + [Fact] + public async Task A_custom_node_of_the_wrong_shape_fails_the_run_and_names_itself() + { + using HttpClient client = _fixture.CreateClient(); + + string id = await _fixture.StartAsync(client, "dsl-wrong-shape", Context(amount: 1m)); + + WorkflowInstance instance = await _fixture.WaitForStatusAsync( + id, InstanceStatus.Failed, InstanceStatus.DeadStopped); + + instance.Status.Should().Be(InstanceStatus.DeadStopped, + "an interpretation failure is deterministic; retrying it only burns attempts"); + instance.AttemptCount.Should().BeLessThanOrEqualTo(1); + + instance.TerminalReason.Should().NotBeNull() + .And.Subject.As().Should().Contain("wrong-shape") + .And.Contain(nameof(DslMessage), "the message should say what shape was required"); + } + // ---- notifications --------------------------------------------------------------------------------------- [Fact] From 99a0924821167865afd76303a9bf3a14cd4ef461 Mon Sep 17 00:00:00 2001 From: Ninja Date: Thu, 20 Aug 2026 22:09:49 +0100 Subject: [PATCH 8/8] docs: fork workflow authoring into a C# guide and a DSL guide The wiki carried the whole compiled-authoring surface plus a 16-recipe appendix, and the DSL had a full reference beside it. That left the two paths documented at different depths and in different places. Adds docs/workflow-authoring-guide.md: the complete C# reference, mirroring the DSL guide section for section. Every built-in executor with its real constructor and behaviour, the full gate, notification, trigger and audit surfaces, both middleware seams, registration, versioning, a section on choosing between the two front ends, and an options appendix with every default. Written from the source, which corrected a few things the wiki had wrong: the exception type is ApiCallFailureException, HumanApprovalExecutor is identity work and the gate is what pauses, FanInExecutor cannot be a barrier target, and there is no SubWorkflow API. A "sharp edges" section states those and the rest rather than leaving them to be discovered. The wiki's authoring chapter now opens with the fork: two ways in, what each reaches, and where to read next for either. Its appendix moved into the two guides so a recipe sits beside the field reference it uses; the heading stays as a signpost, so existing deep links still land. Also commits docs/dsl-authoring-guide.md, which the wiki has linked to since phase 5 but which .gitignore's docs/* rule kept out of the repository. --- README.md | 14 +- docs/dsl-authoring-guide.md | 1389 ++++++++++++++++++++++++++++ docs/wiki.md | 424 ++------- docs/workflow-authoring-guide.md | 1484 ++++++++++++++++++++++++++++++ 4 files changed, 2942 insertions(+), 369 deletions(-) create mode 100644 docs/dsl-authoring-guide.md create mode 100644 docs/workflow-authoring-guide.md diff --git a/README.md b/README.md index 21ea2fa..8ef54b2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,12 @@ builder.Services `OrderWorkflow` must implement `IWorkflowDefinition` or `IWorkflowDefinition`. Use `WorkflowBuildContext.Node(...)` to attach host executors and declare approval gates. +There are two ways to author a workflow, and both produce an `IWorkflowDefinition` on the same +runtime: **in C#**, as above, or **as a JSON document** ([Authoring with the DSL](#authoring-with-the-dsl) +below). Code computes, documents compose; a host can run both at once. Complete references: +[Authoring workflows in C#](docs/workflow-authoring-guide.md) and +[Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md). + ### Declaring executor gates A node attached with no gate block runs autonomously. Pass a gate block to require a human decision, either always or under a predicate: @@ -476,9 +482,13 @@ DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. An invalid document fails startup. A published `(name, version)` is immutable, enforced by a canonical hash of the document. Routes: `GET /dsl/schema`, `/dsl/nodes`, `/dsl/functions`, -`/dsl/documents`, and `POST /dsl/validate`. +`/dsl/documents`, and `POST /dsl/validate`. The ordinary catalog reports which front end authored +each version: `GET /workflows/{name}` carries `source` — `dsl` or `compiled` — and, for a document, +its `documentHash`. -Full walkthrough: [Authoring with the DSL](docs/wiki.md#authoring-with-the-dsl). +Full walkthrough: [Authoring with the DSL](docs/wiki.md#authoring-with-the-dsl) and the complete +reference, [Authoring workflows with the Abacus DSL](docs/dsl-authoring-guide.md) — whose mirror for +the compiled path is [Authoring workflows in C#](docs/workflow-authoring-guide.md). Schema: [docs/schema/abacus-workflow-dsl-1.0.json](docs/schema/abacus-workflow-dsl-1.0.json). ## Audit records diff --git a/docs/dsl-authoring-guide.md b/docs/dsl-authoring-guide.md new file mode 100644 index 0000000..2178f56 --- /dev/null +++ b/docs/dsl-authoring-guide.md @@ -0,0 +1,1389 @@ +# Authoring workflows with the Abacus DSL + +A complete reference for building a workflow definition as a JSON document, covering every framework +capability and how — or whether — a document reaches it. + +Mirror of [Authoring workflows in C#](workflow-authoring-guide.md): the same runtime, the same +catalog, the same gates and events — reached from JSON instead of code. The +[wiki](wiki.md#two-ways-to-author-a-workflow) introduces both and is the operational manual behind +them. The schema is at +[`docs/schema/abacus-workflow-dsl-1.0.json`](schema/abacus-workflow-dsl-1.0.json) and served live +from `GET /dsl/schema`. + +--- + +## Contents + +- [1. The model](#1-the-model) +- [2. The envelope](#2-the-envelope) +- [3. Document anatomy](#3-document-anatomy) +- [4. AbEx — the expression language](#4-abex--the-expression-language) +- [5. Templates](#5-templates) +- [6. Node kinds](#6-node-kinds) +- [7. Edges](#7-edges) +- [8. Approval gates](#8-approval-gates) +- [9. Notifications and events](#9-notifications-and-events) +- [10. Domain events: publishing, waiting, triggering](#10-domain-events-publishing-waiting-triggering) +- [11. Failure, retry and limits](#11-failure-retry-and-limits) +- [12. Custom nodes](#12-custom-nodes) +- [13. Registration and hosting](#13-registration-and-hosting) +- [14. Validation and diagnostics](#14-validation-and-diagnostics) +- [15. Versions, identity and drift](#15-versions-identity-and-drift) +- [16. What a document inherits for free](#16-what-a-document-inherits-for-free) +- [17. Framework coverage map](#17-framework-coverage-map) +- [18. Declared but not yet enforced](#18-declared-but-not-yet-enforced) +- [19. Not expressible](#19-not-expressible) +- [Appendix A — worked variations](#appendix-a--worked-variations) +- [Appendix B — full field reference](#appendix-b--full-field-reference) + +--- + +## 1. The model + +> **The governing rule: the DSL composes, it never computes.** +> +> A document declares *which* nodes exist, *how* they connect, and *when* an edge is taken. It never +> carries behaviour. Every unit of work is a capability the host already shipped — a built-in node +> kind, or a custom node registered by name. + +Three consequences follow, and they explain most of the design: + +1. **There is no `delegate` kind and never will be.** Arbitrary code is precisely what a document + must not carry. When the DSL cannot express something, the answer is *register a node*. +2. **A document's ceiling is the host's node catalog**, not the JSON syntax. Extending the DSL is an + engineering task (ship a factory), not an authoring one. +3. **A document is safe to accept from outside the build.** It cannot execute, reach the filesystem, + open a socket the host has not allow-listed, or loop unboundedly. + +A DSL document registers as an ordinary `IWorkflowDefinition`. It appears in the same catalog, starts +through the same route, checkpoints through the same store, and is controlled by the same endpoints +as a compiled workflow. Nothing downstream of registration knows the difference. + +--- + +## 2. The envelope + +Every DSL node sends and receives one message type. That is what makes every edge type-check by +construction, and what lets a checkpoint serialize without a bespoke converter. + +```json +{ + "ctx": { "orderId": "ORD-1", "amount": 100 }, + "data": { "net": 100, "vat": 20, "total": 120 }, + "meta": { "node": "price", "superstep": 2, "attempt": 1 } +} +``` + +| Part | What it is | +| --- | --- | +| `ctx` | The **start context**, frozen. Copied through every node unchanged, so an expression at any depth can read it. A compiled node closes over C# scope; a document has none, so the envelope carries one. | +| `data` | The **current value**. What a node reads, and what it replaces. | +| `meta` | Provenance the interpreter maintains. Read-only. | + +Two nodes bracket every DSL graph and are not declared in the document: + +- **`$entry`** converts the start context into the first envelope. Without it nothing would run: the + runner sends the deserialized context typed as `JsonElement`, and the engine routes by type. +- **`$exit`** unwraps the envelope to produce the workflow result — **the result is `data`, not the + envelope**. The context is machinery, not an answer. If `data` is not an object it is wrapped as + `{ "value": … }` so the result shape stays predictable. + +Their ids begin with `$`, which a declared node id cannot, so they can never collide. + +--- + +## 3. Document anatomy + +```json +{ + "dsl": "abacus.workflow/1.0", + "name": "order-settlement", + "version": "1.2.0", + "description": "Prices an order, escalates large ones, settles.", + + "context": { "type": "object", "required": ["orderId"] }, + "start": "price", + "output": ["settle"], + + "nodes": [ … ], + "edges": [ … ], + + "triggers": [ … ], + "notifications": { … }, + "onFailure": [ … ], + "audit": { … }, + "limits": { … } +} +``` + +| Field | Required | Purpose | +| --- | --- | --- | +| `dsl` | ✔ | Media identifier selecting schema and interpreter. Currently `abacus.workflow/1.0`. | +| `name` | ✔ | Registry key. Lowercase kebab, `^[a-z][a-z0-9-]{0,63}$`. | +| `version` | ✔ | SemVer. Instances pin it; a published version is immutable. | +| `description` | | Shown in the catalog and used as the audit record description. | +| `context` | | JSON Schema the start payload must satisfy. Enforced on every start request. | +| `start` | ✔ | The node the run begins at. | +| `output` | | Nodes whose `data` becomes the result. Defaults to every terminal node. | +| `nodes` | ✔ | 1–500 nodes. | +| `edges` | | 0–2000 edges. | +| `triggers` | | Topics that start an instance. | +| `notifications` | | Emission level, per-node overrides, SSE on/off. | +| `onFailure` | | Failure classification rules. | +| `audit` | | Declares an audit record shape. | +| `limits` | | `maxAttempts`, `maxLifetimeHours`. | + +`dsl` is versioned deliberately. A future `1.1` adds optional fields and stays readable by a `1.0` +interpreter; a `2.0` does not, and is refused by major version rather than failing on some field it +does not recognise. + +### The context schema + +This is the DSL's answer to a compiled workflow's `TContext`. It is a full JSON Schema, and a start +request that fails it is rejected with **400** and per-field errors before any instance row is +created: + +```json +"context": { + "type": "object", + "required": ["orderId", "amount"], + "properties": { + "orderId": { "type": "string", "minLength": 1 }, + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "enum": ["GBP", "USD", "EUR"] } + } +} +``` + +Omit it and any JSON object is accepted. + +--- + +## 4. AbEx — the expression language + +Conditions, guards, correlation keys and projections need *some* computation. The grammar is closed +on purpose: **total** (no expression over any document can throw), **pure** (no I/O, no state), and +**statically checkable** (every function resolved at validation time). + +### Roots + +| Root | Binds to | Example | +| --- | --- | --- | +| `$` | The current `data` | `$.total`, `$.lines[0].sku` | +| `$ctx` | The frozen start context | `$ctx.orderId` | +| `$run` | Run identity | `$run.instanceId`, `$run.tenantId`, `$run.workflow`, `$run.version`, `$run.attempt`, `$run.superstep`, `$run.now` | + +Every path starts with one of these three. There is deliberately **no `$node.`**: the engine is +message-passing, a prior node's output is not ambiently available, and a root that pretended +otherwise would be a lie the interpreter could not keep. Carry values forward in `data` — that is +what a `transform` node is for. + +### Grammar + +``` +expr := or +or := and ( "||" and )* +and := cmp ( "&&" cmp )* +cmp := add ( ("=="|"!="|"<"|"<="|">"|">=") add )? -- non-associative +add := mul ( ("+"|"-") mul )* +mul := unary ( ("*"|"/"|"%") unary )* +unary := ("!"|"-") unary | primary +primary := literal | path | call | "(" expr ")" +path := ("$"|"$ctx"|"$run") ( "." ident | "[" integer "]" )* +literal := number | 'single-quoted' | "double-quoted" | true | false | null +``` + +Precedence, loosest to tightest: `||`, `&&`, comparison, `+ -`, `* / %`, unary `!` `-`. + +Comparison is **non-associative**: `a < b < c` is refused at validation rather than silently +comparing a boolean to a number. Write `a < b && b < c`. + +String literals prefer single quotes, because a document is already inside JSON: `"$.status == 'settled'"` +needs no escaping, `"\"settled\""` does. + +### Functions + +The complete list. An unknown name is a **validation error** with a nearest-match suggestion, never a +runtime surprise. + +| Function | Arity | Result | +| --- | --- | --- | +| `len(x)` | 1 | Characters of a string, elements of an array, properties of an object; `0` for anything else | +| `has(path)` | 1 | Whether the path resolved to anything at all. A JSON `null` counts as present | +| `lower(s)` / `upper(s)` | 1 | Case folding, invariant culture; absent for a non-string | +| `contains(s, sub)` | 2 | Ordinal substring test | +| `startsWith(s, p)` | 2 | Ordinal prefix test | +| `endsWith(s, p)` | 2 | Ordinal suffix test | +| `matches(s, pattern)` | 2 | Regex test. **The pattern must be a string literal**, and matching times out at 200 ms | +| `coalesce(a, b, …)` | 1+ | First argument that is neither absent nor null | +| `number(x)` | 1 | Number, or a parseable string; absent otherwise | +| `string(x)` | 1 | Rendered form; absent stays absent | +| `bool(x)` | 1 | Boolean, or `"true"`/`"false"`; absent otherwise | + +`matches` requires a literal pattern for a reason: a pattern assembled at run time cannot be reviewed +by reading the document, and an unbounded pattern is the one genuinely dangerous construct in the +grammar. The timeout means a pathological pattern is a non-match, never a stalled dispatcher. + +### Semantics + +These are the rules worth learning before they surprise you. + +**Absence is a value.** A path that does not resolve yields *absent*. It never throws. + +**Absence makes every comparison false — including `!=`.** + +``` +$.missing == 1 → false +$.missing != 1 → false ← not true +has($.missing) → false ← this is how you ask +``` + +A document asking whether a field it never set differs from a value must not be told "yes". + +**Conditions are strictly boolean.** Only `true` is true. + +``` +"when": "$.flag" → true only if flag is boolean true +"when": "$.total" → false, even for 429.50 +"when": "$.name" → false, even for "abc" +"when": "0" → false +"when": "''" → false +``` + +There is no truthiness ladder to remember. Compare explicitly: `$.total > 0`, `len($.name) > 0`. + +**Comparison is JSON-typed.** Number-to-number is numeric, string-to-string is ordinal, anything +cross-type is `false`. No coercion ladder — `$.count == "3"` is false. + +**Arithmetic is decimal, and numbers only.** + +``` +0.1 + 0.2 → 0.3 ← these documents price orders +1 / 0 → absent ← not an error +'a' + 'b' → absent ← + does not concatenate; that is what templates are for +``` + +**Short-circuiting works**, which makes the guard idiom cheap and safe: + +``` +"when": "has($.order) && $.order.total > 25000" +``` + +### Determinism + +> `$run.now` is **forbidden in edge conditions and gate predicates**, and permitted everywhere else. + +`BuildAsync` runs once per attempt, and a resumed instance must retrace the routing its checkpoint +recorded. A condition that read the clock could take a different branch on resume — silent, +intermittent, close to undebuggable. The validator refuses it statically (`DSL0413`). + +Need time-based routing? Compute it once in a `transform` and compare against that: + +```json +{ "id": "stamp", "kind": "transform", "set": { "startedAt": "$run.now" } }, +{ "from": "stamp", "to": "expired", "when": "$.startedAt < '2026-01-01'" } +``` + +--- + +## 5. Templates + +A `{{ … }}` placeholder inside a string evaluates a **full AbEx expression** and renders it as text. +Templates appear in `http` URLs, headers and bodies, and in `llm` prompts. + +```json +"url": "https://ledger.internal/v1/orders/{{ $ctx.orderId }}", +"body": "{\"amount\":{{ $.total }},\"ref\":\"{{ upper($ctx.orderId) }}\"}" +``` + +- An **absent** placeholder renders as empty string — a template never fails a run over a missing + field. +- An **unterminated** placeholder is emitted verbatim rather than truncating the rest of the string. +- Numbers render without trailing zeros (`1.50` → `1.5`), booleans as `true`/`false`, objects and + arrays as JSON. + +**Templates and expressions are different surfaces.** A field is one or the other, never both: + +| Convention | Fields | +| --- | --- | +| Bare AbEx expression | `when`, `select`, `set` values, `payload` values, `correlationKey`, `contextFrom`, `notify.payload` values, `audit.key` | +| `{{ }}` template | `url`, `headers`, `body`, `prompt`, `system` | + +--- + +## 6. Node kinds + +Every node shares these fields: + +```json +{ + "id": "settle", + "kind": "http", + "description": "Posts the settlement to the ledger.", + "gate": { … }, + "notify": { "name": "settled", "payload": { "ref": "$.body.reference" } } +} +``` + +`id` is the identity everything else keys off — gate policy, node state, per-node notification +overrides, the graph endpoint. **Renaming a node in a published version orphans any tenant policy +written against the old id**; bump the version instead. + +### `transform` — projection + +The only node that computes, and it computes only through AbEx. + +```json +{ "id": "price", "kind": "transform", + "set": { + "orderId": "$ctx.orderId", + "net": "$ctx.amount", + "vat": "$ctx.amount * 0.2", + "total": "$ctx.amount * 1.2", + "tier": "coalesce($ctx.tier, 'standard')" + }, + "replace": false } +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `set` | required | Target path → expression. Dotted targets create intermediate objects: `"order.total"` writes `{ "order": { "total": … } }` | +| `replace` | `false` | `false` merges into existing `data`; `true` discards it first | + +**Every expression reads `data` as it was *before* the transform.** The order properties happen to be +written in cannot change the result: + +```json +"set": { "a": "$.a + 1", "b": "$.a + 10" } +``` + +With `data = { "a": 1 }` this yields `{ "a": 2, "b": 11 }` — `b` reads the old `a`, not the new one. + +An expression resolving to absent writes `null`. + +**Produces:** the `set` map merged into (or replacing) `data`. + +### `http` — outbound call + +```json +{ "id": "settle", "kind": "http", + "method": "POST", + "url": "https://ledger.internal/v1/settlements", + "headers": { "X-Order": "{{ $ctx.orderId }}", "Accept": "application/json" }, + "body": "{\"order\":\"{{ $ctx.orderId }}\",\"amount\":{{ $.total }}}", + "allowedHosts": ["ledger.internal"], + "timeoutSeconds": 30, + "successCodes": [200, 201, 202], + "sendIdempotencyKey": true } +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `method` | `GET` | `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD` | +| `url` | required | Templated | +| `headers` | none | Values templated | +| `body` | none | Templated | +| `timeoutSeconds` | `30` | 1–600 | +| `successCodes` | `200,201,202,204` | Anything else raises `ApiCallFailureException` | +| `allowedHosts` | none | Egress allow-list. **Required** when the host enforces egress | +| `sendIdempotencyKey` | `true` | Sends `Idempotency-Key: {instance}:{node}:{attempt}` | + +This is the framework's `ApiCallExecutor`, hosted inside the DSL node — the egress guard, the +idempotency key, the `Retry-After` parsing and the typed failure exception all behave exactly as they +do for a compiled workflow. Nothing is reimplemented. + +**Produces:** `{ "status": 200, "body": … }`. A JSON response body is parsed so it is addressable +(`$.body.reference`); a non-JSON body lands as a string. + +### `llm` — model call + +```json +{ "id": "summarise", "kind": "llm", + "model": "gpt-4o", + "system": "You summarise orders for an operations team.", + "prompt": "Summarise order {{ $ctx.orderId }} totalling {{ $.total }}.", + "promptVersion": "v3", + "temperature": 0.2, + "maxTokens": 500, + "streamDeltas": false, + "emitCompletion": true } +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `model` | required | Resolved through `IChatClientResolver`, or a single registered `IChatClient` | +| `system` / `prompt` | `prompt` required | Templated | +| `promptVersion` | none | Travels into drift middleware and the completion event | +| `temperature`, `maxTokens` | provider default | | +| `streamDeltas` | `false` | Emits transient `llm.delta` events | +| `emitCompletion` | `true` | Emits one `llm.completed` carrying model, tokens, cost and latency | + +**Produces:** `{ text, value, model, inputTokens, outputTokens, costUsd, finishReason, elapsedMs }` — +so a document can branch on cost or token count, not just on the text. + +### `delay` — durable wait + +```json +{ "id": "cool-off", "kind": "delay", "for": "PT4H" } +``` + +ISO-8601 duration. Writes a timer row rather than blocking, so a long delay costs no execution +capacity. + +**Produces:** the envelope **unchanged**. A delay is about *when* the next node runs, not about what +it receives — losing the payload would make every delay need a transform after it. + +> Requires an `ITimerService` registration. See [§18](#18-declared-but-not-yet-enforced). + +### `approval` — human decision as a node + +```json +{ "id": "sign-off", "kind": "approval" } +``` + +Identity work; the pause is the point. The node **always carries a gate**, whether or not the +document spells one out — an `approval` node with no `gate` block behaves as +`{ "mode": "requireApproval" }`. Add a `gate` block to configure assignees, quorum or expiry. + +Use this when the decision belongs in the graph. Use a `gate` on a working node when the decision is +configuration *about* that node. + +**Produces:** the envelope unchanged. + +### `publish` — emit a domain event + +```json +{ "id": "announce", "kind": "publish", + "topic": "orders.settled", + "payload": { "order": "$ctx.orderId", "total": "$.total" }, + "correlationKey": "$ctx.orderId", + "scope": "local" } +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `topic` | required | Dot-segmented topic | +| `payload` | whole `data` | Explicit projection. Defaulting to `data` rather than the envelope matters — a subscriber should receive the message, not this workflow's context | +| `correlationKey` | none | Expression; lets a waiting instance match this message | +| `scope` | `local` | `distributed` requires a broker that supports it, checked at build | + +**Produces:** the envelope unchanged — publishing is a side effect on the way past, so the node drops +into an existing edge without rewiring the graph. + +### `wait-event` — park until a message arrives + +```json +{ "id": "await-payment", "kind": "wait-event", + "topic": "payment.settled", + "correlationKey": "$ctx.orderId", + "timeout": "P3D", + "onExpiry": "deadStop" } +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `topic` | required | Pattern: `*` matches one segment, `#` the remainder | +| `correlationKey` | none | Receive only messages carrying this key | +| `timeout` | none | ISO-8601 | +| `onExpiry` | `deadStop` | `deadStop` terminates; `resume` continues so the graph can handle it | + +Runs twice: the first pass registers a durable subscription and parks (the instance checkpoints and +releases its lease, so a three-day wait costs nothing); after delivery the runner resumes and the +second pass returns the payload. + +**Produces:** `data` becomes the delivered payload. `ctx` survives the park, so `$ctx.orderId` still +resolves afterwards. + +### `fan-in` — barrier aggregation + +```json +{ "id": "join", "kind": "fan-in", "into": "branches" } +``` + +The target of a barrier edge. Holds each branch's arrival and emits once the last one lands; the +expected count is read from the barrier edge in the document. + +**Produces:** `{ "": [ …each branch's data… ] }`, default `into` is `"items"`. + +Cannot carry a gate — a barrier aggregates work that has already happened, so pausing it would gate +nothing. + +### `custom` — a registered node + +```json +{ "id": "score", "kind": "custom", "node": "score-risk", + "with": { "model": "v3", "threshold": 0.82 } } +``` + +See [§12](#12-custom-nodes). + +--- + +## 7. Edges + +One shape covers everything: `from` and `to`, either of which may be a list. + +```json +"edges": [ + { "from": "a", "to": "b" }, + { "from": "b", "to": "large", "when": "$.total > 25000" }, + { "from": "b", "to": "small", "when": "$.total <= 25000" }, + { "from": "large", "to": ["notify-ops", "notify-customer"] }, + { "from": ["notify-ops", "notify-customer"], "to": "join" }, + { "from": "c", "to": ["one", "two", "three"], "select": "$.chosenIndices" } +] +``` + +| Shape | Behaviour | +| --- | --- | +| `from: "a", to: "b"` | Sequential | +| `+ "when": ""` | Traversed only when the expression is boolean `true` | +| `from: "a", to: ["b","c"]` | Fan-out to every target | +| `+ "select": ""` | Fan-out to the subset the expression names by index | +| `from: ["a","b"], to: "c"` | Fan-in barrier — `c` runs once every source has delivered | + +| Field | Default | Meaning | +| --- | --- | --- | +| `when` | none | Condition. Must be deterministic. Not valid on a barrier | +| `select` | none | Fan-out only. A number picks one target; an array picks several. Out-of-range indices are ignored | +| `label` | none | Shown in the graph view | +| `idempotent` | `false` | Permits a duplicate unconditional edge | + +**Branching is two conditional edges out of one node** — there is no `switch`. Make the predicates +exhaustive: a message matching neither simply stops there, and the run completes with no output. + +**A duplicate unconditional edge is refused** (`DSL0208`), because which one fires is ambiguous. Two +*conditional* edges between the same pair are fine — that is exactly how a branch with a fallback is +written. Set `idempotent: true` if the repeat is genuinely intended. + +**Cycles are allowed only when something on them yields.** Polling and wait-and-recheck are +legitimate, but a cycle of pure compute nodes is a hot spin that occupies a dispatcher until the +lifetime cap. Put a `delay`, `wait-event` or `approval` node on the cycle (`DSL0303`). + +--- + +## 8. Approval gates + +A gate on any node makes the run pause for a human. Every option the compiled `ApprovalGateBuilder` +offers is expressible. + +```json +"gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance", "user:cfo"], + "requireApprovers": 2, + "expiresAfter": "PT8H", + "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] }, + "allowModification": true, + "requireSegregationOfDuties": true, + "locked": true +} +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `mode` | required | `autonomous` (no gate), `requireApproval` (always), `conditional` (when the predicate trips) | +| `when` | required for `conditional` | Deterministic AbEx predicate over the node's input | +| `reason` | none | Surfaced to approvers and on the approval event | +| `assignTo` | none | `user:`, `group:` or `role:` prefixed principals | +| `requireApprovers` | `1` | Quorum | +| `expiresAfter` | `PT24H` | ISO-8601 | +| `onExpiry.action` | `deadStop` | `deadStop`, `reject`, `autoApprove`, `escalate` | +| `onExpiry.assignTo` | | Required when the action is `escalate` | +| `allowModification` | `false` | Approver may amend the node's input | +| `requireSegregationOfDuties` | `false` | The decider may not be the initiator | +| `locked` | `false` | Tenants may tighten this gate, never loosen it | + +Notes that bite: + +- **A gate with `assignTo` refuses an unauthenticated decider** with 403. That is correct, and worth + remembering when testing. +- **Every gated node is reconfigurable per tenant** unless `locked` is set. See + [Tenant executor configuration](wiki.md#tenant-executor-configuration). +- **`fan-in` nodes cannot be gated** (`DSL0501`). +- A `conditional` gate with no `when` is refused (`DSL0502`) — it would never trip, which is the same + as having no gate. + +--- + +## 9. Notifications and events + +### Per-node notification + +```json +{ "id": "price", "kind": "transform", + "set": { "total": "$ctx.amount * 1.2" }, + "notify": { + "name": "priced", + "payload": { "order": "$ctx.orderId", "total": "$.total" } + } } +``` + +Emits `custom.priced` on the instance's event stream after the node succeeds, interleaved correctly +with the lifecycle events around it. The `custom.` prefix is applied by the framework and cannot be +opted out of, so a workflow can never shadow a framework event. The payload is evaluated against the +node's *output* envelope. + +A parked node emits nothing — the notification fires only on a real result. + +### Workflow-level policy + +```json +"notifications": { + "level": "standard", + "stream": true, + "byNode": { "chatty-fan-out": "minimal", "the-interesting-one": "standard" }, + "emits": ["priced", "settled"] +} +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `level` | `standard` | `minimal` (start, output, terminal), `lifecycle` (adds superstep boundaries), `standard` (adds per-node and workflow-defined events) | +| `stream` | `true` | Whether events reach live SSE subscribers | +| `byNode` | none | Per-node level override, in both directions | +| `emits` | none | Names advertised by the catalog. Per-node `notify` names are added automatically | + +> **The durable event log is not optional and cannot be turned off.** `stream: false` switches off +> only the live fan-out; every event is still written and readable at +> `GET /v2/workflows/{name}/instances/{id}/events`. Only the *timing* of observability changes. + +Approvals, control actions, broker deliveries and terminal events are never suppressed at any level — +they are facts about the system, not run chatter. + +--- + +## 10. Domain events: publishing, waiting, triggering + +Three distinct capabilities, all reachable from a document. + +**Publish** — a `publish` node, [§6](#publish--emit-a-domain-event). + +**Wait** — a `wait-event` node, [§6](#wait-event--park-until-a-message-arrives). + +**Trigger** — a message *starts* an instance: + +```json +"triggers": [ + { "topic": "orders.placed" }, + { "topic": "orders.*.amended", "contextFrom": "$.order" } +] +``` + +| Field | Meaning | +| --- | --- | +| `topic` | Pattern. `*` matches one segment, `#` the trailing remainder | +| `correlationKey` | **A literal filter value, not an expression.** The subscription is registered before any message exists, so there is nothing for a path to read. A key written to look like an expression is warned about | +| `contextFrom` | An expression **rooted at the message payload**, projecting it into the workflow's start context. Omit to pass the whole payload through | + +`contextFrom` is a genuine expression because a message *does* exist when it runs. If it resolves to +nothing the whole payload is used, so a mistyped path degrades rather than starting an empty run. + +--- + +## 11. Failure, retry and limits + +```json +"onFailure": [ + { "match": { "exception": "ApiCallFailureException", "status": "5xx" }, "disposition": "retry" }, + { "match": { "exception": "ApiCallFailureException", "status": "4xx" }, "disposition": "deadStop" }, + { "match": { "node": "settle" }, "disposition": "escalate" } +], +"limits": { "maxAttempts": 5, "maxLifetimeHours": 72 } +``` + +Rules are evaluated **in declaration order**; the first match wins. Anything unmatched falls through +to the framework's default classifier, which already knows that a rate limit is worth retrying and a +validation error is not. **A document only has to state where it disagrees.** + +| `match` field | Matches | +| --- | --- | +| `exception` | A framework exception by name — a fixed whitelist, so a document cannot name arbitrary types | +| `status` | An exact code (`404`) or a class (`5xx`); only meaningful for `ApiCallFailureException` | +| `node` | Scopes the rule to one node id | + +Matchable exceptions: `WorkflowDeadStopException`, `ApprovalRejectedException`, +`WorkflowValidationException`, `StructuredOutputException`, `ApiCallFailureException`, +`LlmRateLimitException`, `LlmOverloadedException`, `DslContractException`. + +| Disposition | Effect | +| --- | --- | +| `retry` | Backoff and try again, until `maxAttempts` or `maxLifetimeHours` | +| `deadStop` | Terminal. Retrying cannot help, so attempts are not burned discovering that | +| `escalate` | Terminal, and flagged for operator attention | + +--- + +## 12. Custom nodes + +The extension seam, and the whole reason the DSL has no ceiling. + +```csharp +public sealed class RiskScoringNodeFactory : IDslNodeFactory +{ + public string Name => "score-risk"; + + // Validated against 'with' at registration, so a bad parameter fails startup. + public JsonNode? ParameterSchema => JsonNode.Parse(""" + { + "type": "object", + "required": ["threshold"], + "properties": { + "threshold": { "type": "number", "minimum": 0, "maximum": 1 }, + "model": { "type": "string" } + } + } + """); + + public IHostExecutor Create(DslNodeContext context) + => new RiskScorer( + context.Node.Id, + context.Parameters["threshold"]!.GetValue(), + context.Require()); +} + +internal sealed class RiskScorer(string id, decimal threshold, IRiskService risk) + : HostExecutor(id) +{ + protected override async ValueTask ExecuteCoreAsync( + DslMessage input, IWorkflowContext context, CancellationToken cancellationToken) + { + decimal score = await risk.ScoreAsync(input.Data, cancellationToken); + + var data = (JsonObject)(input.Data ?? new JsonObject()).DeepClone(); + data["score"] = score; + data["flagged"] = score > threshold; + + return input.WithData(data); + } +} +``` + +```json +{ "id": "score", "kind": "custom", "node": "score-risk", + "with": { "threshold": 0.82, "model": "v3" } } +``` + +Rules enforced at build time: + +- The executor **must** be a `HostExecutor`. Every edge carries the envelope, + and a node emitting anything else would break the *next* edge rather than its own. +- The executor's id **must** match the declared node id — gate policy and node state key off it. +- An unregistered `node` name fails registration (`DSL0601`), not the first run. +- `with` is validated against `ParameterSchema` (`DSL0602`). + +`DslNodeContext` gives a factory everything a compiled definition gets: + +| Member | Purpose | +| --- | --- | +| `Node` | The declared node model, including its expressions | +| `Document` | The whole document | +| `Build` | The `WorkflowBuildContext` — instance id, tenant, attempt, audit | +| `Parameters` | The `with` object, empty rather than null | +| `Require()` / `Optional()` | Resolve host services | + +A custom node is hosted the same way a built-in one is, so it gets bound expression roots, its +declared `notify`, and result projection for free. The author writes `ExecuteCoreAsync` and nothing +else. + +--- + +## 13. Registration and hosting + +```csharp +builder.Services.AddWorkflowHost(builder.Configuration) + .AddWorkflow() // compiled, unchanged + + .UseDsl() // routes work before any document exists + .ConfigureDsl(dsl => + { + dsl.EnforceEgress = true; + dsl.Policy = DslPolicy.Default with { MaxNodes = 200 }; + }) + + .AddDslNode(new RiskScoringNodeFactory()) + .AddDslNode("stamp", ctx => new StampExecutor(ctx.Node.Id)) // delegate form + + .AddDslWorkflow("workflows/order-settlement.json") + .AddDslWorkflowText(embeddedDocument, "embedded:order") + .AddDslWorkflowsFromDirectory("workflows/", "*.workflow.json", recursive: true); + +app.MapWorkflowApi(); +app.MapDslApi(); +``` + +**Order does not matter.** Documents are parsed and validated once the container is built, against +the *complete* node catalog — so `AddDslNode` may come after `AddDslWorkflow`. Making correctness +depend on the order composition happened to be written in would be a trap. + +**An invalid document fails startup**, with *every* diagnostic from *every* failing document. Three +broken documents should take one startup to fix, not three. + +Documents load from a directory in a stable ordinal order, so a conflict between two documents naming +the same `(name, version)` names the same one every time rather than looking intermittent. + +### Routes + +| Route | Purpose | +| --- | --- | +| `GET /dsl/schema` | The published JSON Schema, for editor completion | +| `GET /dsl/nodes` | Built-in kinds and every registered custom node with its parameter schema | +| `GET /dsl/functions` | The closed expression vocabulary with arities | +| `GET /dsl/documents` | Registered documents and their content hashes | +| `POST /dsl/validate` | Validate a document without registering it | +| `GET /workflows/{name}` | Not a DSL route, but reports `source` (`dsl` or `compiled`) and, for a document, its `documentHash` | + +`POST /dsl/validate` is what an authoring tool calls: it validates against the **live host's** +catalog, which an offline linter cannot do. It reflects registered node names back to the caller, so +give it the same authorization as the catalog routes. + +--- + +## 14. Validation and diagnostics + +Two phases, because one cannot do the job. + +**Phase 1 — JSON Schema** checks shape: required properties, `kind`-discriminated variants, id and +SemVer patterns, ISO-8601 durations, principal formats, enum values. + +**Phase 2 — the semantic validator** checks everything a schema cannot express. A schema cannot +compare two array items, follow a reference, walk a graph, parse a sub-language, or know what the +host has registered. + +The phases stop where continuing would be noise: a document failing the schema is not read into the +model, because reporting forty type errors from a half-understood document buries the one that +matters. + +### Diagnostic codes + +| Code | Check | +| --- | --- | +| `DSL0101` | `dsl` major version is supported | +| `DSL0102` | Content hash conflicts with an already-published `(name, version)` | +| `DSL0103` | Document is valid JSON and an object | +| `DSL0104` | Document matches the JSON Schema | +| `DSL0201` | Node ids are unique | +| `DSL0202` | `start` names a real node | +| `DSL0203` | Every `output` entry names a real node | +| `DSL0207` | Every edge endpoint exists *(with a nearest-match suggestion)* | +| `DSL0208` | No duplicate unconditional edge unless `idempotent` | +| `DSL0301` | Every node is reachable from `start` *(warning)* | +| `DSL0302` | No reachable dead end outside `output` *(warning)* | +| `DSL0303` | No cycle without a `delay`, `wait-event` or `approval` on it | +| `DSL0304` | Barrier sources are reachable, so the barrier can release | +| `DSL0401` | Every expression parses | +| `DSL0412` | Every function is known, with correct arity | +| `DSL0413` | No non-deterministic value in an edge condition or gate predicate | +| `DSL0414` | Expression depth within the limit | +| `DSL0501` | No gate on a non-gateable kind | +| `DSL0502` | A `conditional` gate has a `when` | +| `DSL0503` | `escalate` expiry names escalation assignees | +| `DSL0601` | Every `custom` node names a registered factory | +| `DSL0602` | `with` satisfies the factory's parameter schema | +| `DSL0603` | `http` nodes declare allowed hosts when egress is enforced | +| `DSL0701` | Document, node, edge and expression limits | + +Every diagnostic carries a **JSON Pointer**: + +``` +DSL0412 error /nodes/3/gate/when Unknown function 'lookupCustomer'. Did you mean 'coalesce'? +DSL0207 error /edges/5/to Edge targets 'setle', which is not a node. Did you mean 'settle'? +DSL0301 warn /nodes/7 Node 'notify' is unreachable from 'price'. +``` + +### Skipped checks + +`DSL0102`, `DSL0601`, `DSL0602` and `DSL0603` need a host. Validating offline reports them as +**skipped** rather than passed, in a `skippedChecks` array: + +```json +{ "valid": true, "skippedChecks": ["DSL0601", "DSL0602", "DSL0603", "DSL0102"] } +``` + +A check that silently did not run is worse than one that openly did not, because only the second can +be acted on. + +### Limits + +| Limit | Default | +| --- | --- | +| Document size | 1 MB | +| Nodes | 500 | +| Edges | 2000 | +| Expression depth | 32 | +| Expression length | 2048 characters | +| Regex match timeout | 200 ms | + +All configurable **down** through `ConfigureDsl`, none up. + +--- + +## 15. Versions, identity and drift + +A document registers as `(name, version)` and inherits the framework's rule: **a published version is +immutable.** Instances pin their version, and editing a document under a version its instances are +running would rewrite history mid-flight. + +Identity is a canonical SHA-256 (RFC 8785 JCS) of the document: + +- Reformatting, whitespace and property reordering **do not** change the hash. +- One byte of behaviour **does**. + +Registering a document whose `(name, version)` is already known with a different hash is a startup +failure naming both hashes (`DSL0102`). Editing a workflow means bumping the version — which the +compiled path already demands, stated in a way a document author actually encounters. + +`GET /dsl/documents` reports each registered document's hash, which answers the operational question +directly: *is this instance running the document I am looking at?* + +--- + +## 16. What a document inherits for free + +None of this is declared in a document, because none of it is the document's business. DSL nodes +traverse exactly the same runtime as compiled ones. + +| Capability | How it applies | +| --- | --- | +| **Checkpointing and resume** | Every superstep checkpoints; a parked instance releases its lease and resumes on any replica | +| **At-least-once execution** | Same lease-based dispatch and retry semantics | +| **Executor middleware** | Every DSL node runs through the host's `IExecutorMiddleware` pipeline — logging, metrics, redaction, drift detection | +| **Workflow middleware** | Same `IWorkflowMiddleware` wrapping of the whole run | +| **Redaction** | The same rules apply to envelopes. Worth noting: the envelope deliberately carries more in flight (`ctx` travels with every message), so redaction matters more, not less | +| **Egress control** | `http` nodes go through the same `EgressGuard`. A document cannot widen an allow-list the host has fixed | +| **Multi-tenancy** | Definitions are global; instances are tenant-scoped; `$run.tenantId` is readable | +| **Instance controls** | `cancel`, `suspend`, `resume`, `retry`, `rerun` work identically | +| **Observability** | Event history, SSE streaming with `Last-Event-ID` catch-up, instance logs, the graph endpoint | +| **Tenant gate configuration** | Gated DSL nodes are reconfigurable per tenant unless `locked` | +| **Approval flow** | Quorum, expiry, escalation, segregation of duties, modification | + +The catalog reports a DSL node's `kind` in its metadata, so `GET /workflows/{name}/versions/{v}/nodes` +and the graph view describe a document exactly as they describe a compiled definition. + +--- + +## 17. Framework coverage map + +Every capability the compiled authoring surface offers, and how a document reaches it. + +| Framework capability | DSL | +| --- | --- | +| `IWorkflowDefinition` | The document itself; `context` schema replaces `TContext` | +| `TransformExecutor` | `kind: "transform"` | +| `DelegateExecutor` | ✖ **By design.** Use a `custom` node | +| `ApiCallExecutor` | `kind: "http"` | +| `LlmExecutor` | `kind: "llm"` | +| `DelayExecutor` | `kind: "delay"` | +| `HumanApprovalExecutor` | `kind: "approval"` | +| `FanInExecutor` | `kind: "fan-in"` | +| `PublishDomainEventExecutor` | `kind: "publish"` | +| `WaitForDomainEventExecutor` | `kind: "wait-event"` | +| Custom `HostExecutor` | `kind: "custom"` + `IDslNodeFactory` | +| `RawNode` / `AIAgent` / sub-workflow bindings | ✖ Not exposed | +| `AddEdge` | `{ from, to }` | +| `AddEdge(condition)` | `{ from, to, when }` | +| `AddFanOutEdge` | `{ from, to: [...] }` | +| `AddFanOutEdge(targetSelector)` | `{ from, to: [...], select }` | +| `AddFanInBarrierEdge` | `{ from: [...], to }` | +| `WithOutputFrom` | `output` | +| Edge labels / `idempotent` | `label`, `idempotent` | +| `ApprovalGateBuilder` (all options) | `gate` block | +| `.Locked()` | `gate.locked` | +| `Classify(WorkflowFailure)` | `onFailure` rules, falling through to the default classifier | +| `INotifyingWorkflow` | `notifications` | +| `INodeNotifier.NotifyAsync` | `notify` on a node | +| `IDomainEventTriggeredWorkflow` | `triggers` | +| `IAuditedWorkflowDefinition` | `audit` — **shape only**, see §18 | +| `IContextValidatingWorkflow` | `context` schema | +| `MaxAttempts` / lifetime | `limits` | +| `IWorkflowContext.QueueStateUpdateAsync` etc. | ✖ Not exposed; a `custom` node has full access | +| Middleware, redaction, egress, tenancy, checkpointing | Inherited — see §16 | + +--- + +## 18. Declared but not yet enforced + +These fields are accepted by the schema and parsed into the model, but **nothing acts on them yet**. +They are documented here rather than quietly omitted, so nobody relies on behaviour that does not +exist. + +| Field | Intended behaviour | Current behaviour | +| --- | --- | --- | +| `strict` | Enforce node `input`/`output` schemas at run time; a violation is dead-stop | Parsed, ignored | +| node `input` / `output` | Per-node contract schemas | Parsed, ignored | +| `result` | Schema the workflow result is expected to satisfy | Parsed, ignored | +| `llm.structuredOutput` | Parse model output into a declared shape | Parsed, ignored — the node returns text | +| `audit.key` | Business key the audit record opens under | Parsed and validated as an expression, ignored | +| `audit.sections` | Sections the record may contain | **Declares the record shape**, so the state endpoint returns a non-null `audit` object — but no DSL node writes entries into it | + +The `audit` gap is the largest: a document can declare a record shape, and it will be advertised, but +only a `custom` node can actually record anything into it (via `context.Build.Audit`). A document of +built-in nodes produces an empty record. + +Two host prerequisites are also worth stating: + +- **`ITimerService` is not registered by the framework or the shipped host.** A `delay` node needs + one; register an implementation before using that kind. +- **`IChatClient` or `IChatClientResolver` must be registered** for `llm` nodes. With a single + `IChatClient`, the `model` field is passed through as the model id but does not select a client. + +--- + +## 19. Not expressible + +Deliberate boundaries, so you meet them here rather than in an error message. + +**No loops or iteration.** There is no `foreach`, and no way to sum or map over an array. Fan-out +over branches is the intended shape for parallel work. Unbounded iteration in a checkpointed engine +has real semantics to establish first — checkpoint size, superstep count, and what a retry means +mid-iteration. + +> This is the most commonly hit limit. A workflow that must aggregate over a collection needs a +> `custom` node — which is a five-line executor, not a workaround. + +**No sub-workflows.** The engine supports composing workflows; resolving and version-pinning one +document from another needs its own design. + +**No runtime publication.** Documents load from disk or memory at startup. A management API that +accepted them at run time would change the registry from immutable to mutable, which touches version +resolution, dispatch, authorization, tenancy and in-flight instance migration. + +**No arbitrary code.** No scripting, no reflection by name into arbitrary types, no `eval`. Only +registered factories. + +**No export from C#.** A compiled definition cannot be emitted as a document. The DSL is a different +way in, not a serialization of the compiled path. + +--- + +## Appendix A — worked variations + +Each mirrors a variation from the +[C# guide's appendix](workflow-authoring-guide.md#appendix-a--worked-variations), so the two front +ends can be read side by side. + +### A.1 Linear + +```json +{ + "dsl": "abacus.workflow/1.0", + "name": "linear", "version": "1.0.0", + "context": { "type": "object", "required": ["orderId", "amount"] }, + "start": "price", "output": ["finish"], + "nodes": [ + { "id": "price", "kind": "transform", "set": { "total": "$ctx.amount * 1.2" } }, + { "id": "finish", "kind": "transform", "set": { "order": "$ctx.orderId", "total": "$.total", "status": "'done'" } } + ], + "edges": [ { "from": "price", "to": "finish" } ] +} +``` + +### A.2 Branch + +```json +"start": "classify", "output": ["escalate", "settle"], +"nodes": [ + { "id": "classify", "kind": "transform", "set": { "amount": "$ctx.amount" } }, + { "id": "escalate", "kind": "transform", "set": { "route": "'manual'" } }, + { "id": "settle", "kind": "transform", "set": { "route": "'auto'" } } +], +"edges": [ + { "from": "classify", "to": "escalate", "when": "$.amount > 10000" }, + { "from": "classify", "to": "settle", "when": "$.amount <= 10000" } +] +``` + +Make the predicates exhaustive — a message matching neither stops there. + +### A.3 Fan-out and fan-in + +```json +"start": "split", "output": ["join"], +"nodes": [ + { "id": "split", "kind": "transform", "set": { "seed": "$ctx.amount" } }, + { "id": "ops", "kind": "transform", "set": { "channel": "'ops'", "value": "$.seed + 1" } }, + { "id": "cust", "kind": "transform", "set": { "channel": "'customer'", "value": "$.seed + 2" } }, + { "id": "join", "kind": "fan-in", "into": "notified" } +], +"edges": [ + { "from": "split", "to": ["ops", "cust"] }, + { "from": ["ops", "cust"], "to": "join" } +] +``` + +Result: `{ "notified": [ { "channel": "ops", … }, { "channel": "customer", … } ] }`. + +### A.4 Approval gate + +```json +{ "id": "settle", "kind": "http", + "url": "https://ledger.internal/v1/settlements", + "allowedHosts": ["ledger.internal"], + "method": "POST", + "gate": { + "mode": "conditional", + "when": "$.total > 25000", + "reason": "RegulatedSettlement", + "assignTo": ["group:finance", "user:cfo"], + "requireApprovers": 2, + "expiresAfter": "PT8H", + "onExpiry": { "action": "escalate", "assignTo": ["group:exec"] }, + "allowModification": true, + "requireSegregationOfDuties": true, + "locked": true + } } +``` + +### A.5 Durable delay + +```json +"nodes": [ + { "id": "submit", "kind": "transform", "set": { "submitted": "true" } }, + { "id": "coolOff", "kind": "delay", "for": "PT4H" }, + { "id": "confirm", "kind": "transform", "set": { "confirmed": "$.submitted" } } +], +"edges": [ + { "from": "submit", "to": "coolOff" }, + { "from": "coolOff", "to": "confirm" } +] +``` + +The envelope passes through the delay unchanged, so `confirm` still sees `submitted`. + +### A.6 HTTP call + +```json +{ "id": "fetch", "kind": "http", + "method": "GET", + "url": "https://catalog.internal/v1/skus/{{ $ctx.sku }}", + "headers": { "Accept": "application/json" }, + "allowedHosts": ["catalog.internal"], + "successCodes": [200, 404], + "timeoutSeconds": 10 } +``` + +Then branch on the status the node produced: + +```json +{ "from": "fetch", "to": "found", "when": "$.status == 200" }, +{ "from": "fetch", "to": "missing", "when": "$.status == 404" } +``` + +Listing `404` as a success code is what turns "not found" into a branch rather than a failure. + +### A.7 LLM node + +```json +{ "id": "summarise", "kind": "llm", + "model": "gpt-4o", + "system": "You write one-sentence order summaries.", + "prompt": "Order {{ $ctx.orderId }}, total {{ $.total }}. Summarise.", + "promptVersion": "v2", + "temperature": 0.2, + "maxTokens": 200 } +``` + +Branch on cost, which the node put on the envelope: + +```json +{ "from": "summarise", "to": "review", "when": "$.costUsd > 0.5" } +``` + +### A.8 Started by an event + +```json +"triggers": [ { "topic": "orders.placed", "contextFrom": "$.order" } ], +"context": { "type": "object", "required": ["orderId"] }, +"start": "handle", +"nodes": [ { "id": "handle", "kind": "transform", "set": { "sawOrder": "$ctx.orderId" } } ] +``` + +A message `{ "order": { "orderId": "ORD-9" }, "meta": … }` starts an instance whose context is +`{ "orderId": "ORD-9" }`. + +### A.9 Publish and wait + +```json +"nodes": [ + { "id": "request", "kind": "publish", + "topic": "payment.requested", + "payload": { "order": "$ctx.orderId", "amount": "$.total" }, + "correlationKey": "$ctx.orderId" }, + { "id": "await", "kind": "wait-event", + "topic": "payment.settled", + "correlationKey": "$ctx.orderId", + "timeout": "P3D", + "onExpiry": "resume" }, + { "id": "done", "kind": "transform", + "set": { "order": "$ctx.orderId", "paid": "$.amount" } } +], +"edges": [ + { "from": "request", "to": "await" }, + { "from": "await", "to": "done" } +] +``` + +`onExpiry: "resume"` lets the graph handle a timeout instead of dead-stopping. + +### A.10 Quiet a chatty workflow + +```json +"notifications": { + "level": "minimal", + "byNode": { "score": "standard" }, + "stream": false +} +``` + +Minimal everywhere, loud on the one interesting node, no live streaming — and the durable log still +records everything. + +### A.11 A custom node doing the domain work + +```json +"nodes": [ + { "id": "load", "kind": "http", "url": "https://data.internal/v1/case/{{ $ctx.caseId }}", + "allowedHosts": ["data.internal"] }, + { "id": "score", "kind": "custom", "node": "score-risk", "with": { "threshold": 0.8 } }, + { "id": "route", "kind": "transform", "set": { "outcome": "$.flagged" } } +], +"edges": [ + { "from": "load", "to": "score" }, + { "from": "score", "to": "route" } +] +``` + +The document orchestrates; the engineer's node computes. That division is the design. + +--- + +## Appendix B — full field reference + +```jsonc +{ + "dsl": "abacus.workflow/1.0", // required + "name": "kebab-case-name", // required + "version": "1.0.0", // required, SemVer + "description": "…", + "context": { /* JSON Schema */ }, + "result": { /* JSON Schema — not yet enforced */ }, + "strict": false, // not yet enforced + "start": "node-id", // required + "output": ["node-id"], // defaults to terminal nodes + + "nodes": [ // required, 1–500 + { + "id": "node-id", // required + "kind": "transform", // required + "description": "…", + "input": { /* not yet enforced */ }, + "output": { /* not yet enforced */ }, + + "gate": { + "mode": "conditional", // autonomous | requireApproval | conditional + "when": "$.total > 25000", // required for conditional + "reason": "…", + "assignTo": ["group:finance"], + "requireApprovers": 1, + "expiresAfter": "PT24H", + "onExpiry": { "action": "deadStop", "assignTo": [] }, + "allowModification": false, + "requireSegregationOfDuties": false, + "locked": false + }, + + "notify": { "name": "priced", "payload": { "total": "$.total" } }, + + // kind: transform + "set": { "path.to.field": "" }, + "replace": false, + + // kind: http + "method": "GET", + "url": "