diff --git a/.cz.toml b/.cz.toml index 9bc7427..c9ab1f8 100644 --- a/.cz.toml +++ b/.cz.toml @@ -2,5 +2,5 @@ name = "cz_conventional_commits" tag_format = "v$version" version_scheme = "semver" -version = "1.1.0" +version = "1.0.0" update_changelog_on_bump = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8db82f3..aaa1318 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,97 +80,3 @@ jobs: with: name: core-java8-libs path: build/libs - - ChicoryJava17: - runs-on: ubuntu-latest - env: - CI: true - steps: - - uses: actions/checkout@v4 - - - name: Check out blue-quickjs - uses: actions/checkout@v4 - with: - repository: bluecontract/blue-quickjs - path: blue-quickjs - submodules: recursive - - - name: Set up JDK - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'corretto' - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - with: - version: 9.8.0 - run_install: false - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version-file: 'blue-quickjs/.nvmrc' - cache: 'pnpm' - cache-dependency-path: 'blue-quickjs/pnpm-lock.yaml' - - - name: Cache emsdk - uses: actions/cache@v4 - with: - path: blue-quickjs/tools/emsdk - key: emsdk-${{ runner.os }}-${{ hashFiles('blue-quickjs/tools/scripts/emsdk-version.txt') }} - - - name: Install blue-quickjs dependencies - run: pnpm install --frozen-lockfile - working-directory: blue-quickjs - - - name: Install emsdk - run: bash tools/scripts/setup-emsdk.sh - working-directory: blue-quickjs - - - name: Build blue-quickjs runtime - run: | - WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build - pnpm exec nx build quickjs-wasm - pnpm exec nx build abi-manifest - pnpm exec nx build quickjs-runtime - working-directory: blue-quickjs - - - name: Setup Gradle - uses: gradle/gradle-build-action@v2 - - - name: Execute full Gradle build - run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - - - name: Generate Chicory benchmark report - run: ./gradlew :quickjs-chicory:benchmarkTest -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - - - name: Confirm no native runtime dependency creep - run: ./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath - - - name: Verify Chicory no-Node smoke test - run: ./gradlew :quickjs-chicory:test --tests '*LambdaPackagingSmokeTest' -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" - - - name: Archive test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: chicory-java17-test-results - path: | - build/reports - quickjs-chicory/build/reports - - - name: Archive Chicory parity and benchmark reports - uses: actions/upload-artifact@v4 - if: always() - with: - name: chicory-parity-benchmark-reports - path: quickjs-chicory/build/reports/*.json - - - name: Archive libs - uses: actions/upload-artifact@v4 - with: - name: chicory-java17-libs - path: | - build/libs - quickjs-chicory/build/libs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1da179d..c60ede2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,21 +63,17 @@ jobs: working-directory: blue-quickjs - name: Build blue-quickjs runtime - run: | - WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build - pnpm exec nx build quickjs-wasm - pnpm exec nx build abi-manifest - pnpm exec nx build quickjs-runtime + run: pnpm exec nx build quickjs-runtime working-directory: blue-quickjs - name: Setup Gradle uses: gradle/gradle-build-action@v2 - name: Execute Gradle build - run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" + run: ./gradlew clean build -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" - name: Execute Gradle publish - run: ./gradlew publish -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" -PblueQuickJsRoot="$GITHUB_WORKSPACE/blue-quickjs" + run: ./gradlew publish -Dblue.quickjs.root="$GITHUB_WORKSPACE/blue-quickjs" - name: Execute Gradle release env: @@ -96,7 +92,5 @@ jobs: name: artifacts path: | build/libs - quickjs-chicory/build/libs build/publications - quickjs-chicory/build/publications build/jreleaser diff --git a/README.md b/README.md index 8f61002..ffea53e 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,15 @@ -# Blue Contract Java +# Blue Coordination Java -Java processors for executable Blue repository contracts. +Java processors for executable Blue Coordination repository contracts. -This library lets a Java application process Blue documents that contain real -repository contracts such as operations, workflow steps, timeline channels, -triggered events, document updates, embedded scopes, and checkpoints. +This library lets a Java application process Blue documents that declare +Coordination contracts in their `contracts` map: operations, sequential +workflows, update steps, compute steps, triggered events, composite channels, +embedded scopes, and checkpoints. -Blue processing is deterministic: given the same input document and the same -ordered timeline entries, the processor must produce the same canonical output -document and emit the same events. -This is the compatibility contract across all Blue-compliant document processors — -including this Java processor, the open-source -JavaScript processor in [blue-js](https://github.com/bluecontract/blue-js), -the hosted processor in [MyOS](https://myos.blue), and any other processor. - -Read the language and runtime rules in the -[Blue Language Specification](https://language.blue/docs/reference/specification). -Read the timeline/provider model in the -[Timelines white paper](https://language.blue/docs/reference/timelines-white-paper). +The processor is deterministic. Given the same initialized document and the +same ordered input events, it produces the same canonical output document, +triggered events, gas accounting, and output BlueId. ## Install @@ -29,577 +21,185 @@ repositories { } dependencies { - implementation "blue.contract:blue-contract-java:1.0.0" + implementation "blue.coordination:blue-coordination-java:1.0.0" } ``` -This project targets Java 8 bytecode and depends on published artifacts: +The project targets Java 8 bytecode and depends on: ```groovy -api "blue.language:blue-language-java:2.0.0" -api "blue.repo:blue-repo-java:1.3.0" +api "blue.language:blue-language-java:3.0.0" +api "blue.repo:blue-repo-java:2.0.1" +api "blue.bex:blue-bex-java:1.0.0" ``` -## Counter In One Document +## Register Processors + +Most applications should register the Coordination processor set on a +repository-configured `Blue` instance: + +```java +import blue.coordination.processor.CoordinationProcessors; +import blue.language.Blue; +import blue.repo.BlueRepository; + +BlueRepository repository = BlueRepository.v1_3_0(); +Blue blue = repository.configure(new Blue()); +blue.nodeProvider(repository.nodeProvider()); + +CoordinationProcessors.registerWith(blue); +``` + +For direct `DocumentProcessor` construction: + +```java +import blue.coordination.processor.CoordinationProcessors; +import blue.language.processor.DocumentProcessor; + +DocumentProcessor processor = + CoordinationProcessors.configure(DocumentProcessor.builder()) + .build(); +``` -The example below is a complete executable Blue document. It declares: +`CoordinationProcessors` intentionally does not register a concrete processor +for `Coordination/Timeline Channel`. Applications should provide their own +timeline provider channel processor or register a small local test processor +for fixtures that use `Coordination/Timeline Entry`. -- a concrete timeline provider channel; -- two operations, `increment` and `decrement`; -- two operation-backed sequential workflows; -- one update step that mutates `/counter`; -- one trigger-event step that emits a chat message after each operation. +## Counter Document -The channel is a concrete MyOS timeline channel. Do not use -`Conversation/Timeline Channel` directly for executable production processing; -that type is generic timeline vocabulary. Concrete providers such as -`MyOS/MyOS Timeline Channel` own executable timeline routing. +This is a complete executable Blue document. The contracts are the program: ```yaml name: Counter counter: 0 contracts: ownerChannel: - type: MyOS/MyOS Timeline Channel + type: Coordination/Timeline Channel timelineId: counter-demo increment: - description: Increment the counter by the given number - type: Conversation/Operation + type: Coordination/Operation channel: ownerChannel request: type: Integer incrementImpl: - type: Conversation/Sequential Workflow Operation + type: Coordination/Sequential Workflow Operation operation: increment steps: - - type: Conversation/Update Document - changeset: - - op: replace - path: /counter - val: ${event.message.request + document('/counter')} - - - type: Conversation/Trigger Event - event: - type: Conversation/Chat Message - message: Counter is now ${document('/counter')} - - decrement: - description: Decrement the counter by the given number - type: Conversation/Operation - channel: ownerChannel - request: - type: Integer - - decrementImpl: - type: Conversation/Sequential Workflow Operation - operation: decrement - steps: - - type: Conversation/Update Document - changeset: - - op: replace - path: /counter - val: ${document('/counter') - event.message.request} - - - type: Conversation/Trigger Event - event: - type: Conversation/Chat Message - message: Counter is now ${document('/counter')} + - name: IncrementAndEmit + type: Coordination/Compute + do: + - $let: + name: nextCounter + expr: + $add: + - $document: /counter + - $binding: + name: event + path: /message/request + - $appendChange: + op: replace + path: /counter + val: + $var: nextCounter + - $appendEvent: + type: Coordination/Chat Message + message: + $concat: + - Counter is now + - " " + - $text: + $var: nextCounter + - $return: + changeset: + $changeset: true + events: + $events: true ``` -The document has no code outside the document. The contracts are the program. - -## Run It From Java - -```java -import blue.contract.processor.BlueDocumentProcessors; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.processor.DocumentProcessingResult; -import blue.repo.BlueRepository; - -public final class CounterExample { - private static final String COUNTER_DOCUMENT = - "name: Counter\n" + - "counter: 0\n" + - "contracts:\n" + - " ownerChannel:\n" + - " type: MyOS/MyOS Timeline Channel\n" + - " timelineId: counter-demo\n" + - " increment:\n" + - " description: Increment the counter by the given number\n" + - " type: Conversation/Operation\n" + - " channel: ownerChannel\n" + - " request:\n" + - " type: Integer\n" + - " incrementImpl:\n" + - " type: Conversation/Sequential Workflow Operation\n" + - " operation: increment\n" + - " steps:\n" + - " - type: Conversation/Update Document\n" + - " changeset:\n" + - " - op: replace\n" + - " path: /counter\n" + - " val: ${event.message.request + document('/counter')}\n" + - " - type: Conversation/Trigger Event\n" + - " event:\n" + - " type: Conversation/Chat Message\n" + - " message: Counter is now ${document('/counter')}\n" + - " decrement:\n" + - " description: Decrement the counter by the given number\n" + - " type: Conversation/Operation\n" + - " channel: ownerChannel\n" + - " request:\n" + - " type: Integer\n" + - " decrementImpl:\n" + - " type: Conversation/Sequential Workflow Operation\n" + - " operation: decrement\n" + - " steps:\n" + - " - type: Conversation/Update Document\n" + - " changeset:\n" + - " - op: replace\n" + - " path: /counter\n" + - " val: ${document('/counter') - event.message.request}\n" + - " - type: Conversation/Trigger Event\n" + - " event:\n" + - " type: Conversation/Chat Message\n" + - " message: Counter is now ${document('/counter')}\n"; - - public static void main(String[] args) { - BlueRepository repository = BlueRepository.v1_3_0(); - - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); - - Node document = blue.yamlToNode(COUNTER_DOCUMENT) - .blue(repository.typeAliasBlue()); - DocumentProcessingResult initialized = - blue.initializeDocument(blue.preprocess(document)); - - DocumentProcessingResult afterAlice = blue.processDocument( - initialized.snapshot(), - operationEntry(blue, repository, - "alice-account", - "alice@example.com", - "increment", - 5, - 1L)); - - DocumentProcessingResult afterBob = blue.processDocument( - afterAlice.snapshot(), - operationEntry(blue, repository, - "bob-account", - "bob@example.com", - "decrement", - 2, - 2L)); - - System.out.println(afterBob.resolvedDocument().get("/counter")); // 3 - System.out.println(afterAlice.triggeredEvents().get(0).getAsText("/message")); - System.out.println(afterBob.triggeredEvents().get(0).getAsText("/message")); - System.out.println(afterBob.blueId()); - } - - private static Node operationEntry(Blue blue, - BlueRepository repository, - String accountId, - String email, - String operation, - int request, - long timestamp) { - String yaml = - "type: MyOS/MyOS Timeline Entry\n" + - "timeline:\n" + - " timelineId: counter-demo\n" + - "timestamp: " + timestamp + "\n" + - "actor:\n" + - " type: MyOS/Principal Actor\n" + - " accountId: " + accountId + "\n" + - " email: " + email + "\n" + - "message:\n" + - " type: Conversation/Operation Request\n" + - " operation: " + operation + "\n" + - " request: " + request + "\n"; - - return blue.preprocess(blue.yamlToNode(yaml) - .blue(repository.typeAliasBlue())); - } -} -``` - -Alice sends this timeline entry: +An input event for that channel looks like this: ```yaml -type: MyOS/MyOS Timeline Entry +type: Coordination/Timeline Entry timeline: timelineId: counter-demo timestamp: 1 -actor: - type: MyOS/Principal Actor - accountId: alice-account - email: alice@example.com message: - type: Conversation/Operation Request + type: Coordination/Operation Request operation: increment request: 5 ``` -Bob sends this timeline entry: +After processing, `/counter` is `5`, the workflow emits a chat message, and the +channel checkpoint records the delivered timeline entry so duplicates do not +run twice. -```yaml -type: MyOS/MyOS Timeline Entry -timeline: - timelineId: counter-demo -timestamp: 2 -actor: - type: MyOS/Principal Actor - accountId: bob-account - email: bob@example.com -message: - type: Conversation/Operation Request - operation: decrement - request: 2 -``` - -The result is deterministic: - -- after Alice, `/counter == 5`; -- after Bob, `/counter == 3`; -- Alice's operation emits `Counter is now 5`; -- Bob's operation emits `Counter is now 3`; -- checkpoints are written so duplicate timeline entries do not run twice; -- stale timeline entries are rejected by provider recency rules; -- `result.blueId()` is the content address of the canonical output document. - -## Deterministic Processing Model - -The processor is a deterministic state machine. +## Processing Model Input: 1. one Blue document; -2. a sequence of timeline entries or triggered/lifecycle events. +2. a delivered event, usually a timeline entry or a lifecycle/triggered event. Output: 1. one canonical Blue document; 2. zero or more triggered events; -3. a BlueId for the output document; -4. gas accounting and checkpoints. - -For any document that is understood by all runtimes, this Java processor, the -open-source [blue-js](https://github.com/bluecontract/blue-js) processor, and -MyOS hosted processing are expected to produce the same output document and the -same triggered events for the same ordered inputs. A mismatch is a processor -bug or a version mismatch. - -This is why document processors operate on canonical snapshots instead of -process-local mutable state. You can serialize `result.canonicalDocument()`, -load it again, and continue processing from the same resolved snapshot. - -## Timelines And Fetching - -The document processor does not guess which external events exist. It processes -entries that are delivered to it. - -Timeline providers are responsible for fetching and ordering timeline entries. -A concrete provider channel, such as `MyOS/MyOS Timeline Channel`, tells the -processor how to recognize entries from that provider and when an entry is new -relative to the channel checkpoint. - -Read the -[Timelines white paper](https://language.blue/docs/reference/timelines-white-paper) -for the full model: timeline entries, provider completeness, fetching, -ordering, and how concrete timeline providers hand deterministic event streams -to processors. - -## MyOS SaaS - -[MyOS](https://myos.blue) is the hosted SaaS environment for processing Blue -documents, timelines, and workflows. After signing up, switch to -`Developer Mode` from the top-right corner to access developer-oriented tools. - -Developer documentation is available at -[developers.myos.blue](https://developers.myos.blue/). - -The goal is portability: - -- run locally in Java with this package; -- run locally or in services with [blue-js](https://github.com/bluecontract/blue-js); -- run hosted in MyOS. - -For the same supported document, repository version, and timeline entries, the -canonical output and triggered events should be the same. - -## What Is Supported - -This repository currently provides executable behavior for: - -- `Conversation/Composite Timeline Channel`; -- `Conversation/Operation`; -- `Conversation/Sequential Workflow`; -- `Conversation/Sequential Workflow Operation`; -- `Conversation/Update Document`; -- `Conversation/Trigger Event`; -- `Conversation/JavaScript Code`; -- `MyOS/MyOS Timeline Channel`. - -The underlying `blue-language-java` runtime provides Core processing behavior -used by real repository contracts: - -- `Core/Document Update Channel`; -- `Core/Embedded Node Channel`; -- `Core/Process Embedded`; -- `Core/Channel Event Checkpoint`; -- `Core/Lifecycle Event Channel`; -- `Core/Triggered Event Channel`; -- processing initialized and terminated markers; +3. total gas usage; +4. processing metadata, including the output BlueId. + +Processors operate on canonical snapshots instead of process-local mutable +state. You can serialize a processed document, load it again, and continue +processing from the same resolved state. + +## Supported Contracts + +This library provides executable behavior for: + +- `Coordination/Composite Timeline Channel`; +- `Coordination/Operation`; +- `Coordination/Sequential Workflow`; +- `Coordination/Sequential Workflow Operation`; +- `Coordination/Compute`; +- `Coordination/Update Document`; +- `Coordination/Trigger Event`. + +The underlying `blue-language-java` runtime provides base behavior used by +Coordination documents: + +- `Document Update Channel`; +- `Embedded Node Channel`; +- `Process Embedded`; +- `Channel Event Checkpoint`; +- `Lifecycle Event Channel`; +- `Triggered Event Channel`; +- initialized and terminated markers; - scope boundaries, patch application, snapshots, gas, and checkpointing. -## JavaScript In Workflows - -`Conversation/Update Document` expressions and `Conversation/JavaScript Code` -steps run through QuickJS. - -Available bindings: - -- `event`; -- `eventCanonical`; -- `document(pointer)`; -- `document.canonical(pointer)`; -- `steps`; -- `currentContract`; -- `currentContractCanonical`. - -Example JavaScript code step: - -```yaml -- name: CreateMessage - type: Conversation/JavaScript Code - code: |- - const message = - `Counter is now ${document('/counter')}`; - - return { - events: [ - { - type: "Conversation/Chat Message", - message, - }, - ], - }; -``` - -The Java implementation currently uses a persistent Node bridge to a local -`blue-quickjs` checkout. By default it expects: - -```text -../blue-quickjs -``` - -Override the location if needed: - -```bash -./gradlew test -Dblue.quickjs.root=/path/to/blue-quickjs -``` - -Build the QuickJS runtime first: - -```bash -cd ../blue-quickjs -pnpm nx build quickjs-runtime -``` - -### Experimental Chicory blue-quickjs runtime - -The root `blue-contract-java` artifact remains Java 8 compatible and continues -to use `NodeQuickJsRuntime` by default. Chicory is optional and must be enabled -explicitly. The optional Java 11+ artifact is: - -```groovy -dependencies { - implementation "blue.contract:blue-contract-java:1.0.0" - implementation "blue.contract:blue-contract-java-quickjs-chicory:1.0.0" -} -``` - -The Chicory runtime executes the canonical blue-quickjs wasm32 release artifact -on the JVM. It does not require Node, V8, Javet, JNI, native Wasmtime bindings, -or another native JavaScript runtime in the Chicory evaluation path. - -Treat Chicory as an experimental AWS Lambda / JVM-only runtime because current -benchmarks show it is substantially slower than the Node bridge. The no-Node -classpath smoke and Java 11 AWS Lambda container smoke have passed, so the -remaining blocker is performance and release hardening, not basic packaging. -For the same pinned blue-quickjs artifact, Host.v1 ABI, source wrapper, -bindings, and gas limit, Chicory and `NodeQuickJsRuntime` must return identical -values/errors, `wasmGasUsed`, and `hostGasUsed`. Benchmark reports are -timing-only; gas must match exactly. - -Build, publish locally, and test the optional module with a built `blue-quickjs` -checkout: - -```bash -cd ../blue-quickjs -pnpm install --frozen-lockfile -bash tools/scripts/setup-emsdk.sh -WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build -pnpm exec nx build quickjs-wasm -pnpm exec nx build abi-manifest -pnpm exec nx build quickjs-runtime - -cd ../blue-contract-java -./gradlew :quickjs-chicory:test \ - -PblueQuickJsRoot=../blue-quickjs \ - -Dblue.quickjs.root=../blue-quickjs -./gradlew publishToMavenLocal -PblueQuickJsRoot=../blue-quickjs -``` - -The module verifies the pinned wasm artifact, `engineBuildHash`, Host.v1 -manifest hash, gas version, and deterministic execution profile before -evaluation. Filesystem WASM resolution fails closed unless an expected engine -hash is supplied, and both filesystem and classpath modes require metadata with -`engineBuildHash`, `gasVersion`, `executionProfile`, and `abiManifestHash`. -Classpath-bundled WASM is self-pinned by generated metadata. For packaging, -generate classpath resources with: - -```bash -./gradlew :quickjs-chicory:jar -PblueQuickJsRoot=../blue-quickjs -``` - -Applications can use classpath-pinned WASM resources and inject the runtime -without adding Chicory to the Java 8 core: - -```java -import blue.contract.processor.BlueDocumentProcessorOptions; -import blue.contract.processor.BlueDocumentProcessors; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.chicory.ChicoryBlueQuickJsRuntime; - -JavaScriptRuntime runtime = ChicoryBlueQuickJsRuntime.fromClasspathDefaults(); - -BlueDocumentProcessors.registerWith( - blue, - BlueDocumentProcessorOptions.builder() - .javaScriptRuntime(runtime) - .build()); -``` - -Filesystem WASM resources are also supported, but must be explicitly pinned by -engine hash and must include the same metadata fields: - -```java -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.chicory.BlueQuickJsWasmRuntimeConfig; -import blue.contract.processor.conversation.javascript.chicory.ChicoryBlueQuickJsRuntime; -import java.nio.file.Paths; - -JavaScriptRuntime runtime = new ChicoryBlueQuickJsRuntime( - BlueQuickJsWasmRuntimeConfig.builder() - .preferClasspathResources(false) - .blueQuickJsRoot(Paths.get("/opt/blue-quickjs")) - .expectedEngineBuildHash("f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea") - .build()); -``` +## BEX In Workflows -`NodeQuickJsRuntime` remains the compatibility fallback and parity oracle. -The generated POM for `blue-contract-java` does not depend on Chicory; users -must opt in by depending on `blue-contract-java-quickjs-chicory`. +`Coordination/Compute` is the BEX execution surface. A Compute step applies a +returned `changeset` directly and emits returned `events` directly, so dynamic +patches and events do not need follow-up Update Document or Trigger Event +steps. -Container/Lambda status: CI runs a no-Node classpath smoke test that evaluates -`1 + 2` through `ChicoryBlueQuickJsRuntime.fromClasspathDefaults()`, verifies gas -is charged, and checks that Node, V8, Javet, JNI, native Wasmtime, and other -native JavaScript runtime dependencies are absent from the Chicory runtime -classpath. The same smoke has also passed in Docker using the Java 11 AWS SAM -Lambda build image. A full AWS Lambda runtime/RIE invocation smoke can still be -added later if release policy requires it. +Common workflow bindings: -The generated `quickjs-chicory` metadata currently bridges fields that should -come from upstream blue-quickjs release metadata long term: `gasVersion`, -`executionProfile`, and `abiManifestHash`. If upstream metadata omits -`gasVersion`, the Gradle packaging task writes the Java runtime's pinned -`DEFAULT_GAS_VERSION` into generated classpath metadata. Java consumes and -verifies those generated fields; filesystem WASM metadata still fails closed -when required pinning fields are missing. - -## Registration - -Most applications should use the one-call facade: - -```java -BlueRepository repository = BlueRepository.v1_3_0(); - -Blue blue = repository.configure(new Blue()); -blue.nodeProvider(repository.nodeProvider()); -BlueDocumentProcessors.registerWith(blue); -``` +- `$binding` for `event`, the current `document`, and named step results; +- `$document` for the current document view; +- `$currentContract` for the active workflow contract; +- `$appendChange` and `$changeset` for accumulated patch operations; +- `$appendEvent` and `$events` for accumulated emitted events. -For custom processor construction: - -```java -import blue.contract.processor.BlueDocumentProcessors; -import blue.language.processor.DocumentProcessor; - -DocumentProcessor processor = - BlueDocumentProcessors.configure(DocumentProcessor.builder()) - .build(); -``` - -You can register processor groups directly when needed: - -```java -ConversationProcessors.registerWith(blue); -MyOSProcessors.registerWith(blue); -``` - -## Core Concepts For Developers - -### Documents are data and programs - -A Blue document is regular data. Its `contracts` map declares executable -behavior. A processor does not need app-specific Java code for every workflow; -it reads the contract graph and executes supported contracts. - -### Operations are messages - -An operation is invoked by a `Conversation/Operation Request`, usually carried -as the `message` of a timeline entry: - -```yaml -message: - type: Conversation/Operation Request - operation: increment - request: 5 -``` - -The handler contract references the operation: - -```yaml -incrementImpl: - type: Conversation/Sequential Workflow Operation - operation: increment -``` - -### Channels route events - -Handlers bind to channels. A channel decides whether a delivered event belongs -to that channel. Provider channels also define recency rules so stale entries do -not re-run handlers. - -### Workflows are ordered steps - -Sequential workflows execute step by step. Later steps can read previous named -step results through `steps`. - -### Triggered events continue processing - -`Conversation/Trigger Event` emits a Blue event. Consumers bound to -`Core/Triggered Event Channel` can react to it in the same processing run. +`Coordination/Update Document` accepts literal patch lists only. +`Coordination/Trigger Event` accepts literal event payloads only. ## Build And Test +Run tests: + ```bash ./gradlew test ``` @@ -616,59 +216,41 @@ Publish locally: ./gradlew publishToMavenLocal ``` -Maven Central release publishing follows the same JReleaser setup used by the -other Blue Java artifacts. The GitHub release workflow publishes -`blue.contract:blue-contract-java:1.0.0` from `main` using: - -- `JRELEASER_MAVENCENTRAL_USERNAME`; -- `JRELEASER_MAVENCENTRAL_PASSWORD`; -- `JRELEASER_GPG_PUBLIC_KEY`; -- `JRELEASER_GPG_SECRET_KEY`; -- `JRELEASER_GPG_PASSPHRASE`; -- `JRELEASER_GITHUB_TOKEN`. +## Test Coverage Current test areas: - processor registration; - must-understand failures; -- MyOS timeline matching; -- concrete test timeline provider behavior; -- composite timeline channel routing; +- test timeline provider behavior; +- composite timeline routing; - operation request matching; - sequential workflow execution; +- compute and BEX execution; +- update document batch application; - trigger-event execution; -- JavaScript code execution; -- Core runtime channels; +- runtime channels; - repository-style Counter documents; - snapshot round-trip stress processing. ## Project Layout ```text -src/main/java/blue/contract/processor - BlueDocumentProcessors.java - ConversationProcessors.java - MyOSProcessors.java - -src/main/java/blue/contract/processor/conversation +src/main/java/blue/coordination/processor + CoordinationProcessors.java + CoordinationProcessorOptions.java + CoordinationBexIntrinsics.java CompositeTimelineChannelProcessor.java OperationProcessor.java - OperationRequestMatcher.java SequentialWorkflowProcessor.java SequentialWorkflowOperationProcessor.java TimelineProviderSupport.java + bex/ + merge/ workflow/ - expression/ - javascript/ - -src/main/java/blue/contract/processor/myos - MyOSTimelineChannelProcessor.java ``` ## References -- [Blue Language Specification](https://language.blue/docs/reference/specification) -- [Timelines white paper](https://language.blue/docs/reference/timelines-white-paper) +- [Blue Language Specification](https://github.com/bluecontract/blue-spec) - [blue-js open-source processor](https://github.com/bluecontract/blue-js) -- [MyOS SaaS](https://myos.blue) -- [MyOS developer documentation](https://developers.myos.blue/) diff --git a/build.gradle b/build.gradle index 6777f32..e474a22 100644 --- a/build.gradle +++ b/build.gradle @@ -11,11 +11,11 @@ plugins { id 'org.jreleaser' version '1.13.1' } -group = 'blue.contract' +group = 'blue.coordination' version = determineProjectVersion() base { - archivesName = 'blue-contract-java' + archivesName = 'blue-coordination-java' } repositories { @@ -41,10 +41,12 @@ tasks.withType(JavaCompile).configureEach { } dependencies { - api 'blue.language:blue-language-java:2.0.0' - api 'blue.repo:blue-repo-java:1.3.0' + api 'blue.language:blue-language-java:3.0.0' + api 'blue.repo:blue-repo-java:2.0.1' + api 'blue.bex:blue-bex-java:1.0.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' @@ -58,9 +60,6 @@ compileTestJava { test { useJUnitPlatform() - if (System.getProperty('blue.quickjs.root')) { - systemProperty 'blue.quickjs.root', System.getProperty('blue.quickjs.root') - } reports { junitXml.required = false html.required = true @@ -73,13 +72,12 @@ test { ext.genResourcesDir = file("$buildDir/generated-resources") task generateBuildProperties { - ext.buildPropertiesFile = file("$genResourcesDir/blue/contract/build.properties") + ext.buildPropertiesFile = file("$genResourcesDir/blue/coordination/build.properties") + inputs.property('buildVersion', project.version.toString()) outputs.file(buildPropertiesFile) doLast { - buildPropertiesFile.text = """\ - |blue-contract-java.build.version=$project.version - |blue-contract-java.build.timestamp=${new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")} - """.stripMargin().trim() + buildPropertiesFile.text = ("blue-coordination-java.build.version=" + project.version + "\n" + + "blue-coordination-java.build.timestamp=" + new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")) } } sourceSets.main.output.dir genResourcesDir, builtBy: generateBuildProperties @@ -91,18 +89,18 @@ tasks.withType(GenerateModuleMetadata).configureEach { publishing { publications { maven(MavenPublication) { - groupId = 'blue.contract' - artifactId = 'blue-contract-java' + groupId = 'blue.coordination' + artifactId = 'blue-coordination-java' from components.java pom { - name = 'Blue Contract Java Processor' + name = 'Blue Coordination Java Processor' description = 'Java processors for executable Blue repository contracts.' url = 'https://language.blue' licenses { license { name = 'MIT license' - url = 'https://github.com/bluecontract/blue-contract-java/blob/main/LICENSE' + url = 'https://github.com/bluecontract/blue-coordination-java/blob/main/LICENSE' } } developers { @@ -112,9 +110,9 @@ publishing { } } scm { - url = 'https://github.com/bluecontract/blue-contract-java.git' - connection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' - developerConnection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' + url = 'https://github.com/bluecontract/blue-coordination-java.git' + connection = 'scm:git:git@github.com:bluecontract/blue-coordination-java.git' + developerConnection = 'scm:git:git@github.com:bluecontract/blue-coordination-java.git' } } } @@ -165,7 +163,6 @@ def determineProjectVersion() { if (tomlFile.exists()) { def toml = new groovy.toml.TomlSlurper().parse(tomlFile) return toml.tool.commitizen.version + (!System.getenv('CI') ? '-SNAPSHOT' : '') - } else { - throw new GradleException(".cz.toml file not found") } + return '0.0.0' + (!System.getenv('CI') ? '-SNAPSHOT' : '') } diff --git a/docs/chicory-bluequickjs-spike.md b/docs/chicory-bluequickjs-spike.md deleted file mode 100644 index a49bc10..0000000 --- a/docs/chicory-bluequickjs-spike.md +++ /dev/null @@ -1,195 +0,0 @@ -# Chicory blue-quickjs spike - -This document records the implementation facts, assumptions, blockers, and parity -results for the experimental Java/Chicory blue-quickjs runtime. - -## Preflight - -- Java repo branch: `feature/chicory-bluequickjs-wasm-runtime` -- Java runtime used locally: OpenJDK 21.0.10 -- Gradle wrapper: 8.4 -- Requested default sibling checkout `../blue-quickjs` resolves to `/blue-quickjs` - in this container, but `/` is not writable. The local preflight checkout is: - `/tmp/blue-quickjs` -- blue-quickjs repository: `https://github.com/bluecontract/blue-quickjs` -- blue-quickjs commit: `d462a11818049d7909bbe3ceb36bddd2b532e9cd` -- blue-quickjs QuickJS submodule commit: - `9d1eda6e0d1ec36c279d87380db77fbcc3acbae8` - -## blue-quickjs build commands - -The checked-out `package.json`, `nx.json`, and `libs/quickjs-wasm-build/project.json` -were inspected before running build commands. - -Commands run: - -```bash -pnpm install --frozen-lockfile -bash tools/scripts/setup-emsdk.sh -WASM_VARIANTS=wasm32 WASM_BUILD_TYPES=release pnpm exec nx build quickjs-wasm-build -pnpm exec nx build quickjs-wasm -pnpm exec nx build abi-manifest -pnpm exec nx build quickjs-runtime -``` - -## Canonical artifact - -- Selected wasm artifact: - `/tmp/blue-quickjs/libs/quickjs-wasm-build/dist/quickjs-eval.wasm` -- Packaged copy: - `/tmp/blue-quickjs/libs/quickjs-wasm/dist/wasm/quickjs-eval.wasm` -- Metadata: - `/tmp/blue-quickjs/libs/quickjs-wasm-build/dist/quickjs-wasm-build.metadata.json` -- Wasm magic bytes: `00 61 73 6d` -- Wasm version bytes: `01 00 00 00` -- Wasm size: `659086` -- Variant: `wasm32` -- Build type: `release` -- engineBuildHash / SHA-256 observed in the current local checkout: - `f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea` -- Loader SHA-256: - `11a13f0414e7387f0c9502c8c0ca9479473505d94b824356d015a8d8007637fb` -- Emscripten version: `3.1.56` -- QuickJS version: `2025-09-13` -- Fixed memory: - - initial: observed release artifacts currently use `33554432` or `134217728` - - maximum: must equal initial - - stack: `1048576` - - allowGrowth: `false` -- Determinism flags: - - `-sFILESYSTEM=0` - - `-sALLOW_MEMORY_GROWTH=0` - - `-sINITIAL_MEMORY=` - - `-sMAXIMUM_MEMORY=` - - `-sSTACK_SIZE=1048576` - - `-sALLOW_TABLE_GROWTH=0` - - `-sENVIRONMENT=node,web` - - `-sNO_EXIT_RUNTIME=1` - -## Host.v1 ABI - -- ABI id: `Host.v1` -- ABI version: `1` -- abiManifestHash / `HOST_V1_HASH`: - `e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34` -- Function IDs: - - `1`: `document.get` - - `2`: `document.getCanonical` - - `3`: `emit` - -## Gas/profile metadata - -- Gas version pinned by the Java spike metadata bridge: `8` -- Execution profile pinned by the Java spike metadata bridge: `baseline-v1` -- Current upstream build metadata may include `gasVersion`, but still does not - consistently include all fields the Java runtime wants to verify for - release-mode embedding: `gasVersion`, `executionProfile`, and - `abiManifestHash`. The `quickjs-chicory` build enriches classpath metadata - with those fields as a temporary bridge. Long term, upstream blue-quickjs - release artifacts should publish them directly. - -## Wasm imports - -Parsed directly from `quickjs-eval.wasm`: - -| Module | Name | Signature | Notes | -| --- | --- | --- | --- | -| `env` | `abort` | `() -> ()` | Emscripten support import; should be deterministic fatal if invoked. | -| `env` | `__assert_fail` | `(i32, i32, i32, i32) -> ()` | Emscripten support import; should be deterministic fatal if invoked. | -| `host` | `host_call` | `(i32, i32, i32, i32, i32) -> i32` | Required Host.v1 dispatcher import. | -| `env` | `emscripten_date_now` | `() -> f64` | Emscripten support import; should be a deterministic stub and must not expose wall-clock time. | -| `env` | `emscripten_resize_heap` | `(i32) -> i32` | Memory growth is disabled; should return failure deterministically. | - -## Wasm exports - -Parsed directly from `quickjs-eval.wasm`: - -- `memory` -- `__wasm_call_ctors` -- `malloc` -- `free` -- `__indirect_function_table` -- `qjs_det_init` -- `qjs_det_eval` -- `qjs_det_set_gas_limit` -- `qjs_det_free` -- `qjs_det_enable_tape` -- `qjs_det_read_tape` -- `qjs_det_enable_trace` -- `qjs_det_read_trace` -- `stackSave` -- `stackRestore` -- `stackAlloc` - -## Baseline test result - -Command: - -```bash -./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Result: - -- `BUILD SUCCESSFUL in 33s` -- 6 actionable tasks executed -- Existing Node bridge tests passed. - -Representative deterministic stress output: - -- QuickJS counter snapshot round-trip stress: - - iterations: `100` - - totalGas: `18700` - - minGas: `187` - - maxGas: `187` - - finalBlueId: `qQgDoUkPVc2QPWEJar82QSy2kUahX8HHZJHDWivHmEM` -- No-JS counter snapshot round-trip stress: - - iterations: `100` - - totalGas: `18100` - - minGas: `181` - - maxGas: `181` - - finalBlueId: `9kr8UvMAUAZ2wdk3EreY2ShtC5Q8927uaBdn9QTxqxyj` - -## Current deviations and risks - -1. The local checkout is in `/tmp/blue-quickjs` because `/blue-quickjs` cannot be - created in this container. All local validation commands should pass - `-Dblue.quickjs.root=/tmp/blue-quickjs` or `-PblueQuickJsRoot=/tmp/blue-quickjs`. -2. The raw wasm has deterministic Emscripten support imports in `env` in addition - to `host.host_call`. The Chicory adapter explicitly provides deterministic - JVM-side stubs for these imports and does not run the Emscripten JS loader - under Node. -3. Explicit `gasVersion` and `executionProfile` metadata fields can be absent from - the generated blue-quickjs metadata observed during preflight. Runtime pinning - is addressed by the Java module's generated `engine-metadata.json`, which - enriches the upstream artifact metadata with: - - `gasVersion: 8` - - `executionProfile: baseline-v1` - - `abiManifestHash: e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34` - -## Hardening update - -Date: 2026-05-13 - -The Java/Chicory spike now fails closed for unpinned filesystem WASM artifacts: - -- filesystem resolution requires an explicit expected `engineBuildHash`; -- classpath-bundled resources must include `engineBuildHash`, - `abiManifestHash`, `gasVersion`, and `executionProfile`; -- wrong engine hash, Host.v1 hash, gas version, or execution profile fails - before evaluation; -- generated classpath metadata remains a temporary deterministic bridge until - upstream release metadata carries every required field. - -Node-vs-Chicory parity now treats gas equality as mandatory. For each fixture, -the report compares: - -- ok/error status; -- returned value; -- normalized VM error category/message; -- `wasmGasUsed`; -- `hostGasUsed`. - -Performance is explicitly not a parity signal. Benchmark reports record elapsed -time because it matters for Lambda sizing, but timing differences do not fail -tests. Gas differences always fail. diff --git a/docs/chicory-bluequickjs-test-results.md b/docs/chicory-bluequickjs-test-results.md deleted file mode 100644 index 2b329d9..0000000 --- a/docs/chicory-bluequickjs-test-results.md +++ /dev/null @@ -1,644 +0,0 @@ -# Chicory blue-quickjs test results - -This file records proof commands and outcomes for the Chicory blue-quickjs -runtime branch. - -## Environment - -- Date: 2026-05-12 -- Java repo branch: `feature/chicory-bluequickjs-wasm-runtime` -- Java runtime: OpenJDK 21.0.10 -- Gradle wrapper: 8.4 -- blue-quickjs local checkout: `/tmp/blue-quickjs` -- blue-quickjs commit: `d462a11818049d7909bbe3ceb36bddd2b532e9cd` -- blue-quickjs QuickJS submodule: - `9d1eda6e0d1ec36c279d87380db77fbcc3acbae8` - -## Baseline Node bridge - -Command: - -```bash -./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- `BUILD SUCCESSFUL in 33s` -- 6 actionable tasks executed. - -Representative output: - -```text -QuickJS counter snapshot round-trip stress: iterations=100, -totalGas=18700, minGas=187, maxGas=187, -finalBlueId=qQgDoUkPVc2QPWEJar82QSy2kUahX8HHZJHDWivHmEM - -No-JS counter snapshot round-trip stress: iterations=100, -totalGas=18100, minGas=181, maxGas=181, -finalBlueId=9kr8UvMAUAZ2wdk3EreY2ShtC5Q8927uaBdn9QTxqxyj -``` - -## Pending proof commands - -These commands have now been run for the initial optional module skeleton: - -```bash -./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath -``` - -Outcome: - -- Passed. -- Runtime classpath contains `com.dylibso.chicory:runtime:1.7.5` and - `com.dylibso.chicory:wasm:1.7.5`. -- No Node, V8, Javet, QuickJs4J, Wasmtime, or JNI dependency appeared in the - Chicory module runtime classpath. - -```bash -./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed after adding the empty optional `quickjs-chicory` module. -- Existing root tests still pass with the Node bridge baseline. - -## Core runtime injection layer - -Command: - -```bash -./gradlew test --tests 'blue.contract.processor.conversation.SequentialWorkflowExecutionTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Added coverage proved that `BlueDocumentProcessorOptions` can inject a - `JavaScriptRuntime`, and that `ConversationProcessors.registerWith` can inject - a custom `SequentialWorkflowRunner`. - -Command: - -```bash -./gradlew clean test -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed after the core injection API was added. -- Existing default processor registration behavior remains covered and green. - -## Resource and Host.v1 integrity - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*BlueQuickJsResourceIntegrityTest' \ - --tests '*HostV1ManifestTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Verified canonical wasm resource presence, magic bytes, metadata hash, pinned - engine hash, Host.v1 hash, gas/profile pins, approved import set, and required - exports. -- Verified an incorrect expected engine hash fails closed before evaluation. - -## Deterministic DV codec - -Command: - -```bash -./gradlew :quickjs-chicory:test --tests '*DeterministicValueCodecTest' -``` - -Outcome: - -- Passed. -- Golden encodings matched the blue-quickjs DV documentation. -- Allowed values round-tripped. -- Rejection cases covered NaN, infinities, negative zero, duplicate/unsorted map - keys, indefinite lengths, tags, half/float32, overlong strings, oversized - encoded values, excessive depth, arrays, and maps. - -Command: - -```bash -./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Full current `quickjs-chicory` test set is green. - -## Source wrapper and result parser - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*BlueQuickJsSourceWrapperTest' \ - --tests '*BlueQuickJsResultParserTest' -``` - -Outcome: - -- Passed. -- Verified expression, block, and raw source wrapping matches `evaluate.mjs`. -- Verified VM `RESULT` / `ERROR` output parsing with gas trailers and malformed - output rejection. - -Command: - -```bash -./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Full current `quickjs-chicory` test set remains green. - -## Host.v1 dispatcher - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryDocumentHostTest' \ - --tests '*ChicoryHostCallAbiTest' -``` - -Outcome: - -- Passed. -- Covered `document.get`, `document.getCanonical`, metadata override behavior, - JSON Pointer escaping, missing values, emit limit envelope behavior, malformed - requests, oversized request/response limits, unknown function IDs, reentrant - calls, and internal failure containment. - -Command: - -```bash -./gradlew :quickjs-chicory:test -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Full current `quickjs-chicory` test set remains green after Host.v1 - dispatcher additions. - -## Chicory runtime smoke - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Executed the canonical blue-quickjs wasm32 release artifact through Chicory. -- Repeated each smoke expression 100 times and verified no value, wasm gas, or - host gas drift. -- Covered arithmetic, string concatenation, object/array return, and array map. - -## Forbidden surface and OOG boundaries - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryForbiddenSurfaceTest' \ - --tests '*ChicoryOutOfGasTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Forbidden surface parity covered: - - `typeof Date` - - `typeof process` - - `typeof require` - - `Math.random()` - - `eval("1")` - - `Function("return 1")()` - - `new Proxy({}, {})` - - `typeof WeakRef` -- OOG parity/repetition covered: - - host gas limit `0` - - host gas limit `1` - - `while (true) {}` - - large loop - - recursive function - - `document.get` loop -- Each OOG case was repeated 5 times and checked for no Chicory result drift. - -## Chicory vs Node parity - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryVsNodeParityTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed after normalizing the raw Chicory OOG error to the same deterministic - category/message exposed by the Node bridge. -- Generated parity report: - `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` -- Covered: - - simple arithmetic expression - - event binding expression - - currentContract expression - - steps binding expression - - document simple value - - document canonical value - - document metadata lookup - - array map/reduce - - JSON.stringify deterministic key ordering - - block mode object result - - block mode array result - - forbidden global - - out-of-gas loop - -Report status: `passed`; mismatches: `[]`. - -## Chicory workflow injection - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicorySequentialWorkflowExecutionTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Verified `SequentialWorkflowRunner.withJavaScriptRuntime(new - ChicoryBlueQuickJsRuntime(...))` can be injected through - `BlueDocumentProcessorOptions` and used by real workflow execution. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryCounterSnapshotRoundTripStressTest' \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Ran 100 Chicory-backed counter workflow iterations through canonical snapshot - round trips. -- Verified counter progression, snapshot/BlueId preservation, positive gas, and - stable gas for equivalent operations. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - -PblueQuickJsRoot=/tmp/blue-quickjs \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Full current `quickjs-chicory` test set is green including smoke, resource, - DV, Host.v1, workflow, and Node parity tests. - -## Resource pinning and no-Node smoke - -Command: - -```bash -./gradlew :quickjs-chicory:clean :quickjs-chicory:jar \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Ran `copyBlueQuickJsWasmResources`. -- Built a jar containing generated pinned Chicory resources. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*LambdaPackagingSmokeTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Evaluated a deterministic fixture using classpath-pinned resources without - configuring a filesystem blue-quickjs root in the runtime config. - -Command: - -```bash -PATH=/usr/bin:/bin ./gradlew :quickjs-chicory:test \ - --tests '*ChicoryBlueQuickJsRuntimeSmokeTest' \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- `command -v node` produced no Node binary on that PATH before the test. -- Chicory smoke fixtures evaluated without Node on PATH. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - -PblueQuickJsRoot=/tmp/blue-quickjs \ - -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed after adding resource-copy/classpath resource support. -- Full current `quickjs-chicory` test set remains green. - -## Final validation sweep - -Command: - -```bash -./gradlew clean test \ - -Dblue.quickjs.root=/tmp/blue-quickjs \ - -PblueQuickJsRoot=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Latest full clean run after adding forbidden-surface, OOG, counter-stress, and - Lambda classpath smoke coverage: `BUILD SUCCESSFUL in 1m 40s` -- 13 actionable tasks executed. -- Covered the existing root test suite plus all current `quickjs-chicory` tests. - -Command: - -```bash -./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath -``` - -Outcome: - -- Passed. -- Runtime classpath contains Chicory JVM artifacts only: - `com.dylibso.chicory:runtime:1.7.5` and - `com.dylibso.chicory:wasm:1.7.5`. -- No Node, V8, Javet, QuickJs4J, Wasmtime, or JNI runtime dependency appears. - -Command: - -```bash -python3 - <<'PY' -import yaml -for path in ['.github/workflows/build.yml', '.github/workflows/release.yml']: - with open(path, 'r', encoding='utf-8') as fh: - yaml.safe_load(fh) - print(path, 'ok') -PY -./gradlew :clean :test -Dblue.quickjs.root=/tmp/blue-quickjs -``` - -Outcome: - -- Passed. -- Workflow YAML parsed successfully. -- Root/core-only test path passed: - `BUILD SUCCESSFUL in 14s`, 6 actionable tasks executed. -- This validates the Java 8-compatible core task path used by the split CI job - without invoking the Java 11+ optional module. - -Lambda-like Docker smoke note: - -- The Java 17/21 Lambda container smoke could not be executed in the original - validation environment. -- The no-Node PATH smoke above is the strongest local substitute performed in - this environment. - -## Remaining environment-limited checks - -- `./gradlew clean test` without a `blue.quickjs.root` override still depends on - the repository default sibling checkout. In this container that path resolves - to `/blue-quickjs`, but `/` is not writable, so the available checkout is - `/tmp/blue-quickjs`. The equivalent full clean validation with explicit - `-Dblue.quickjs.root=/tmp/blue-quickjs` passed. -- Lambda-like Java 17 and Java 21 container smoke tests remain unexecuted in - this environment. - -## Hardening validation - -Date: 2026-05-13 - -Environment updates: - -- Java repo branch: `cursor/chicory-blue-quickjs-runtime-1606` -- blue-quickjs checkout: `/Users/piotr/data/blue-quickjs` -- selected engineBuildHash: - `f91091cb7feb788df340305a877a9cadb0c6f4d13aea8a7da4040b6367d178ea` -- gasVersion: `8` -- executionProfile: `baseline-v1` in generated Java-side metadata - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*BlueQuickJsResourceIntegrityTest' \ - --tests '*ChicoryDocumentHostTest' \ - --tests '*ChicoryHostCallAbiTest' \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs -``` - -Outcome: - -- Passed. -- Covered fail-closed checks for missing filesystem engine hash, wrong engine - hash, wrong Host.v1 hash, wrong gas version, and wrong execution profile. -- Covered Host.v1 document get/canonical, JSON Pointer escaping, missing/invalid - pointer behavior, request/response limits, reentrant rejection, and internal - host failure containment. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryVsNodeParityTest' \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs -``` - -Outcome: - -- Passed. -- Parity report: - `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` -- The report compares ok/error status, value, normalized VM error - category/message, `wasmGasUsed`, and `hostGasUsed`. -- No gas mismatches remained in the expanded fixture set. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*BlueQuickJsSourceWrapperTest' \ - --tests '*ChicoryForbiddenSurfaceTest' \ - --tests '*ChicoryOutOfGasTest' \ - --tests '*LambdaPackagingSmokeTest' \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs -``` - -Outcome: - -- Passed. -- Confirmed source wrapping matches the Node bridge wrapper. -- Confirmed forbidden APIs and out-of-gas boundaries match the Node bridge. -- Confirmed classpath-bundled WASM resources work in a child JVM with - `PATH=/bin`, where `node` is not available. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - --tests '*ChicoryBenchmarkReportTest' \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs -``` - -Outcome: - -- Passed. -- Benchmark report: - `quickjs-chicory/build/reports/blue-quickjs-chicory-benchmarks.json` -- Timing is reported only. Gas equality is asserted. -- Current local timing confirms the expected spike tradeoff: Chicory is much - slower with fresh WASM instances per evaluation, but gas matched exactly. - -Command: - -```bash -docker version -``` - -Outcome: - -- Docker client is installed, but the daemon is not reachable: - `Cannot connect to the Docker daemon at unix:///var/run/docker.sock`. -- Lambda-like Java 17/21 container smoke remains pending. - -Command: - -```bash -./gradlew :quickjs-chicory:test \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs -``` - -Outcome: - -- Passed. -- Full optional module test suite: `BUILD SUCCESSFUL in 4m 56s`. - -## Final stabilization validation - -Date: 2026-05-14 - -Dependency update: - -- Root now resolves `blue.language:blue-language-java:2.0.0` from Maven. -- The resolved artifact exposes `ProcessorFatalException.partialResult()` and - `ProcessorFatalException.totalGas()`. -- Processor-level fatal parity tests use those public accessors directly; no - reflection-based fatal gas probe remains. - -Commands: - -```bash -./gradlew test \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs - -./gradlew clean build \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs - -./gradlew publishToMavenLocal \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs - -./gradlew publish \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs - -./gradlew :quickjs-chicory:dependencies --configuration runtimeClasspath - -./gradlew :quickjs-chicory:test \ - --tests '*LambdaPackagingSmokeTest' \ - -PblueQuickJsRoot=/Users/piotr/data/blue-quickjs \ - -Dblue.quickjs.root=/Users/piotr/data/blue-quickjs - -docker version - -docker run --rm --platform linux/amd64 \ - -v /Users/piotr/data/blue-contract-java:/workspace \ - -v /Users/piotr/data/blue-quickjs:/blue-quickjs:ro \ - -v /Users/piotr/.gradle:/root/.gradle \ - -w /workspace \ - amazon/aws-sam-cli-build-image-java11:latest \ - /bin/bash -lc './gradlew :quickjs-chicory:test --tests "*LambdaPackagingSmokeTest" -PblueQuickJsRoot=/blue-quickjs -Dblue.quickjs.root=/blue-quickjs' -``` - -Outcomes: - -- All Gradle verification commands above passed. -- `quickjs-chicory/build/reports/blue-quickjs-chicory-parity.json` reported - `status: passed`, `caseCount: 33`, and no mismatches. -- `quickjs-chicory/build/reports/blue-quickjs-chicory-benchmarks.json` was - generated as a timing report only; timing is not a pass/fail signal. -- `publishToMavenLocal` produced root and optional Chicory main, sources, and - javadoc jars. -- `publish` produced staged root and optional Chicory main, sources, javadoc, - and POM artifacts under `build/staging-deploy`. -- The root POM depends on `blue.language:blue-language-java:2.0.0`, - `blue.repo:blue-repo-java:1.3.0`, and Jackson; it does not depend on Chicory. -- The optional POM depends on `blue.contract:blue-contract-java`, - `com.dylibso.chicory:runtime`, and Jackson. -- Docker was initially blocked in the sandbox, but was reachable outside it - through Docker Desktop 4.73.0. -- The Java 11 AWS SAM Lambda build image smoke passed: - `BUILD SUCCESSFUL in 28s`. -- The container smoke ran `LambdaPackagingSmokeTest`, which evaluates - classpath-pinned Chicory without Node on `PATH` and checks for native JS - runtime dependency leakage. -- A full AWS Lambda runtime/RIE handler invocation smoke is still optional - follow-up work if release policy requires more than a Java process inside an - AWS Lambda Java image. diff --git a/quickjs-chicory/build.gradle b/quickjs-chicory/build.gradle deleted file mode 100644 index 6cb6159..0000000 --- a/quickjs-chicory/build.gradle +++ /dev/null @@ -1,255 +0,0 @@ -plugins { - id 'java-library' - id 'maven-publish' -} - -group = rootProject.group -version = rootProject.version - -base { - archivesName = 'blue-contract-java-quickjs-chicory' -} - -repositories { - if (!System.getenv('CI')) { - mavenLocal() - } - mavenCentral() -} - -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - withSourcesJar() - withJavadocJar() -} - -def generatedResourcesDir = layout.buildDirectory.dir('generated-resources') -def chicoryResourceDir = generatedResourcesDir.map { - it.dir('blue/contract/processor/quickjs/chicory') -} -def expectedGasVersion = 8 -def expectedExecutionProfile = 'baseline-v1' -def expectedAbiManifestHash = 'e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34' - -tasks.withType(JavaCompile).configureEach { - options.encoding = 'UTF-8' - if (JavaVersion.current().isJava11Compatible()) { - options.release = 11 - } -} - -dependencies { - api project(':') - - implementation 'com.dylibso.chicory:runtime:1.7.5' - implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' - - testImplementation platform('org.junit:junit-bom:5.10.2') - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -tasks.withType(GenerateModuleMetadata).configureEach { - enabled = false -} - -tasks.register('copyBlueQuickJsWasmResources') { - def configuredRoot = providers.gradleProperty('blueQuickJsRoot') - .orElse(providers.systemProperty('blue.quickjs.root')) - inputs.property('blueQuickJsRoot', configuredRoot.orNull ?: '') - outputs.dir(chicoryResourceDir) - doLast { - if (!configuredRoot.present || configuredRoot.get().trim().isEmpty()) { - throw new GradleException('copyBlueQuickJsWasmResources requires -PblueQuickJsRoot or -Dblue.quickjs.root') - } - def root = file(configuredRoot.get()) - def sourceDir = new File(root, 'libs/quickjs-wasm/dist/wasm') - def buildDir = new File(root, 'libs/quickjs-wasm-build/dist') - def wasm = new File(sourceDir, 'quickjs-eval.wasm') - if (!wasm.isFile()) { - wasm = new File(buildDir, 'quickjs-eval.wasm') - } - if (!wasm.isFile()) { - throw new GradleException("Missing canonical quickjs-eval.wasm under ${root}") - } - if (wasm.name.contains('debug') || wasm.name.contains('wasm64')) { - throw new GradleException("Rejected non-release/non-wasm32 artifact: ${wasm}") - } - byte[] wasmBytes = wasm.bytes - if (wasmBytes.length < 4 || wasmBytes[0] != 0 || wasmBytes[1] != 0x61 || wasmBytes[2] != 0x73 || wasmBytes[3] != 0x6d) { - throw new GradleException("Invalid wasm magic bytes: ${wasm}") - } - def metadata = new File(wasm.parentFile, 'quickjs-wasm-build.metadata.json') - if (!metadata.isFile()) { - metadata = new File(buildDir, 'quickjs-wasm-build.metadata.json') - } - if (!metadata.isFile()) { - throw new GradleException("Missing quickjs wasm metadata for ${wasm}") - } - def parsed = new groovy.json.JsonSlurper().parse(metadata) - def release = parsed.variants?.wasm32?.release - if (release == null) { - throw new GradleException('Metadata missing wasm32/release artifact') - } - if (release.buildType != 'release' || release.wasm?.filename != 'quickjs-eval.wasm') { - throw new GradleException('Metadata does not describe canonical wasm32 release artifact') - } - def digest = java.security.MessageDigest.getInstance('SHA-256').digest(wasmBytes).collect { String.format('%02x', it & 0xff) }.join() - if (digest != release.engineBuildHash || digest != release.wasm?.sha256 || digest != parsed.engineBuildHash) { - throw new GradleException("Wasm hash mismatch for ${wasm}") - } - def parsedMap = parsed as Map - if (parsedMap.containsKey('gasVersion') && !(parsed.gasVersion instanceof Number)) { - throw new GradleException('Metadata gasVersion must be numeric') - } - if (parsedMap.containsKey('executionProfile') && !(parsed.executionProfile instanceof CharSequence)) { - throw new GradleException('Metadata executionProfile must be a string') - } - if (parsedMap.containsKey('abiManifestHash') && !(parsed.abiManifestHash instanceof CharSequence)) { - throw new GradleException('Metadata abiManifestHash must be a string') - } - def gasVersion = parsed.gasVersion instanceof Number ? parsed.gasVersion as int : expectedGasVersion - if (gasVersion != expectedGasVersion) { - throw new GradleException("Unsupported gasVersion: expected ${expectedGasVersion}, actual ${gasVersion}") - } - def executionProfile = parsed.executionProfile ?: expectedExecutionProfile - if (executionProfile != expectedExecutionProfile) { - throw new GradleException("Unsupported executionProfile: expected ${expectedExecutionProfile}, actual ${executionProfile}") - } - def abiManifestHash = parsed.abiManifestHash ?: expectedAbiManifestHash - if (abiManifestHash != expectedAbiManifestHash) { - throw new GradleException("Unsupported abiManifestHash: expected ${expectedAbiManifestHash}, actual ${abiManifestHash}") - } - def outDir = chicoryResourceDir.get().asFile - outDir.mkdirs() - new File(outDir, 'quickjs-eval.wasm').bytes = wasmBytes - def enriched = new LinkedHashMap(parsedMap) - // Temporary bridge until upstream quickjs-wasm metadata publishes the - // gas version, execution profile, and ABI manifest hash consistently. - enriched.gasVersion = gasVersion - enriched.executionProfile = executionProfile - enriched.abiManifestHash = abiManifestHash - new File(outDir, 'engine-metadata.json').text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(enriched)) + '\n' - new File(outDir, 'host-v1-hash.txt').text = expectedAbiManifestHash + '\n' - def manifestHex = 'a3666162695f696467486f73742e76316966756e6374696f6e7383a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696401666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e74636765746a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696402666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e746c67657443616e6f6e6963616c6a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736505676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573006b7363686564756c655f696467656d69742d76316561726974790165666e5f6964036665666665637464454d4954666c696d697473a3696d61785f756e697473190400716d61785f726571756573745f6279746573198000726d61785f726573706f6e73655f62797465731840676a735f706174688164656d69746a6172675f736368656d6181a164747970656264766b6572726f725f636f64657381a2637461676a686f73742f6c696d697464636f64656e4c494d49545f45584345454445446d72657475726e5f736368656d61a16474797065646e756c6c6b6162695f76657273696f6e01' - new File(outDir, 'host-v1-manifest.dv').bytes = manifestHex.decodeHex() - } -} - -sourceSets.main.resources.srcDir generatedResourcesDir - -tasks.named('processResources') { - if (project.hasProperty('blueQuickJsRoot') || System.getProperty('blue.quickjs.root')) { - dependsOn tasks.named('copyBlueQuickJsWasmResources') - } -} - -tasks.named('sourcesJar') { - if (project.hasProperty('blueQuickJsRoot') || System.getProperty('blue.quickjs.root')) { - dependsOn tasks.named('copyBlueQuickJsWasmResources') - } -} - -test { - useJUnitPlatform { - excludeTags 'benchmark', 'stress' - } - if (System.getProperty('blue.quickjs.root')) { - systemProperty 'blue.quickjs.root', System.getProperty('blue.quickjs.root') - } - if (project.hasProperty('blueQuickJsRoot')) { - systemProperty 'blue.quickjs.root', project.property('blueQuickJsRoot') - } - testLogging { - events 'PASSED', 'FAILED', 'SKIPPED' - showStandardStreams = true - } -} - -tasks.register('benchmarkTest', Test) { - description = 'Runs Chicory benchmark report tests.' - group = 'verification' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - shouldRunAfter test - useJUnitPlatform { - includeTags 'benchmark' - } - if (System.getProperty('blue.quickjs.root')) { - systemProperty 'blue.quickjs.root', System.getProperty('blue.quickjs.root') - } - if (project.hasProperty('blueQuickJsRoot')) { - systemProperty 'blue.quickjs.root', project.property('blueQuickJsRoot') - } - testLogging { - events 'PASSED', 'FAILED', 'SKIPPED' - showStandardStreams = true - } -} - -tasks.register('stressTest', Test) { - description = 'Runs Chicory stress tests.' - group = 'verification' - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - shouldRunAfter test - useJUnitPlatform { - includeTags 'stress' - } - if (System.getProperty('blue.quickjs.root')) { - systemProperty 'blue.quickjs.root', System.getProperty('blue.quickjs.root') - } - if (project.hasProperty('blueQuickJsRoot')) { - systemProperty 'blue.quickjs.root', project.property('blueQuickJsRoot') - } - testLogging { - events 'PASSED', 'FAILED', 'SKIPPED' - showStandardStreams = true - } -} - -publishing { - publications { - maven(MavenPublication) { - groupId = 'blue.contract' - artifactId = 'blue-contract-java-quickjs-chicory' - from components.java - - pom { - name = 'Blue Contract Java QuickJS Chicory Runtime' - description = 'Experimental optional JVM-only Chicory runtime for blue-quickjs.' - url = 'https://language.blue' - licenses { - license { - name = 'MIT license' - url = 'https://github.com/bluecontract/blue-contract-java/blob/main/LICENSE' - } - } - developers { - developer { - name = 'Blue' - email = 'devsupport@timeline.blue' - } - } - scm { - url = 'https://github.com/bluecontract/blue-contract-java.git' - connection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' - developerConnection = 'scm:git:git@github.com:bluecontract/blue-contract-java.git' - } - } - } - } - - repositories { - maven { - url = rootProject.layout.buildDirectory.dir('staging-deploy') - } - if (!System.getenv('CI')) { - maven { - name = 'local' - url = uri('file:///' + new File(System.getProperty('user.home'), '.m2/repository').absolutePath) - } - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java deleted file mode 100644 index 44f0cf0..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsDeterminismException.java +++ /dev/null @@ -1,11 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -public class BlueQuickJsDeterminismException extends BlueQuickJsResourceException { - public BlueQuickJsDeterminismException(String message) { - super(message); - } - - public BlueQuickJsDeterminismException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java deleted file mode 100644 index 6f8b04c..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsHostDispatcher.java +++ /dev/null @@ -1,328 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public final class BlueQuickJsHostDispatcher { - public static final long TRANSPORT_ERROR = 0xffffffffL; - private static final String LIMIT_EXCEEDED = "LIMIT_EXCEEDED"; - private static final Set BLUE_METADATA_KEYS = Collections.unmodifiableSet(new LinkedHashSet( - Arrays.asList("name", - "description", - "type", - "itemType", - "keyType", - "valueType", - "value", - "items", - "blue", - "blueId", - "schema", - "mergePolicy", - "$previous", - "$pos"))); - - private final Map bindings; - private boolean inProgress; - - public BlueQuickJsHostDispatcher(Map bindings) { - this.bindings = bindings == null - ? Collections.emptyMap() - : Collections.unmodifiableMap(new LinkedHashMap(bindings)); - } - - public synchronized DispatchResult dispatch(int fnId, byte[] requestBytes) { - if (inProgress) { - return DispatchResult.fatal("reentrant host_call"); - } - inProgress = true; - try { - return dispatchInternal(fnId, requestBytes); - } catch (RuntimeException ex) { - return DispatchResult.fatal("host dispatcher failed"); - } finally { - inProgress = false; - } - } - - public DispatchResult dispatchForTestingWithoutGuard(int fnId, byte[] requestBytes) { - return dispatchInternal(fnId, requestBytes); - } - - private DispatchResult dispatchInternal(int fnId, byte[] requestBytes) { - FunctionSpec spec = FunctionSpec.forFnId(fnId); - if (spec == null) { - return DispatchResult.fatal("unknown fn_id " + fnId); - } - if (requestBytes == null) { - return DispatchResult.fatal("request bytes must not be null"); - } - if (requestBytes.length > spec.maxRequestBytes) { - return encodeLimit(spec); - } - Object decoded; - try { - decoded = DeterministicValueCodec.decode(requestBytes); - } catch (DeterministicValueCodec.DeterministicValueException ex) { - return DispatchResult.fatal("failed to decode request: " + ex.getMessage()); - } - if (!(decoded instanceof List)) { - return DispatchResult.fatal("request must be a DV array"); - } - List args = (List) decoded; - if (args.size() != spec.arity) { - return DispatchResult.fatal("fn_id=" + fnId + " expected " + spec.arity + " args"); - } - if (fnId == HostV1Manifest.DOCUMENT_GET_FN_ID) { - return handleDocument(spec, args.get(0), false); - } - if (fnId == HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID) { - return handleDocument(spec, args.get(0), true); - } - if (fnId == HostV1Manifest.EMIT_FN_ID) { - return handleEmit(spec); - } - return DispatchResult.fatal("unknown fn_id " + fnId); - } - - private DispatchResult handleDocument(FunctionSpec spec, Object pointer, boolean canonical) { - if (!(pointer instanceof String)) { - return DispatchResult.fatal("fn_id=" + spec.fnId + " expected string path argument"); - } - if (pointer instanceof String - && ((String) pointer).getBytes(StandardCharsets.UTF_8).length > spec.argUtf8Max) { - return encodeLimit(spec); - } - Object root = canonical ? bindings.get("documentCanonical") : bindings.get("document"); - Object metadata = bindings.get("documentMetadata"); - Object value = documentResult(root, pointer, canonical, metadata); - return encodeOk(spec, value, 1); - } - - private DispatchResult handleEmit(FunctionSpec spec) { - Map err = new LinkedHashMap(); - err.put("code", LIMIT_EXCEEDED); - err.put("details", "emit is not available during expression/code evaluation"); - return encodeErr(spec, err, 1); - } - - private Object documentResult(Object root, Object pointer, boolean canonical, Object metadata) { - String normalized = normalizePointer(pointer); - if (!canonical && metadata instanceof Map && ((Map) metadata).containsKey(normalized)) { - Object value = ((Map) metadata).get(normalized); - return value == null ? null : value; - } - PointerResult resolved = getPointer(root, normalized); - if (!resolved.found) { - return null; - } - return canonical ? resolved.value : simpleValue(resolved.value); - } - - private String normalizePointer(Object pointer) { - if (pointer == null || "".equals(pointer)) { - return "/"; - } - if (!(pointer instanceof String)) { - return null; - } - String string = (String) pointer; - return string.startsWith("/") ? string : "/" + string; - } - - @SuppressWarnings("unchecked") - private PointerResult getPointer(Object root, String pointer) { - if (pointer == null || !pointer.startsWith("/")) { - return PointerResult.missing(); - } - if ("/".equals(pointer)) { - return PointerResult.found(root == null ? null : root); - } - Object current = root; - String[] segments = pointer.substring(1).split("/", -1); - for (String rawSegment : segments) { - String segment = rawSegment.replace("~1", "/").replace("~0", "~"); - if (current instanceof List) { - if (!segment.matches("^(0|[1-9]\\d*)$")) { - return PointerResult.missing(); - } - int index; - try { - index = Integer.parseInt(segment); - } catch (NumberFormatException ex) { - return PointerResult.missing(); - } - List list = (List) current; - if (index >= list.size()) { - return PointerResult.missing(); - } - current = list.get(index); - } else if (current instanceof Map) { - Map map = (Map) current; - if (!map.containsKey(segment)) { - return PointerResult.missing(); - } - current = map.get(segment); - } else { - return PointerResult.missing(); - } - } - return PointerResult.found(current == null ? null : current); - } - - @SuppressWarnings("unchecked") - private Object simpleValue(Object node) { - if (node == null) { - return null; - } - if (node instanceof List) { - List result = new ArrayList(); - for (Object value : (List) node) { - result.add(simpleValue(value)); - } - return result; - } - if (!(node instanceof Map)) { - return node; - } - Map map = (Map) node; - if (map.containsKey("value")) { - Object value = map.get("value"); - return value == null ? null : value; - } - Object items = map.get("items"); - if (items instanceof List) { - List result = new ArrayList(); - for (Object value : (List) items) { - result.add(simpleValue(value)); - } - return result; - } - Map result = new LinkedHashMap(); - for (Map.Entry entry : map.entrySet()) { - if (!BLUE_METADATA_KEYS.contains(entry.getKey())) { - result.put(entry.getKey(), simpleValue(entry.getValue())); - } - } - return result; - } - - private DispatchResult encodeOk(FunctionSpec spec, Object value, int units) { - Map envelope = new LinkedHashMap(); - envelope.put("ok", value); - envelope.put("units", units); - return encodeEnvelope(spec, envelope, true); - } - - private DispatchResult encodeErr(FunctionSpec spec, Map err, int units) { - Map envelope = new LinkedHashMap(); - envelope.put("err", err); - envelope.put("units", units); - return encodeEnvelope(spec, envelope, true); - } - - private DispatchResult encodeLimit(FunctionSpec spec) { - Map err = new LinkedHashMap(); - err.put("code", LIMIT_EXCEEDED); - Map envelope = new LinkedHashMap(); - envelope.put("err", err); - envelope.put("units", 0); - return encodeEnvelope(spec, envelope, false); - } - - private DispatchResult encodeEnvelope(FunctionSpec spec, Map envelope, boolean allowLimitFallback) { - byte[] bytes; - try { - bytes = DeterministicValueCodec.encode(envelope); - } catch (DeterministicValueCodec.DeterministicValueException ex) { - return allowLimitFallback ? encodeLimit(spec) : DispatchResult.fatal("failed to encode envelope: " + ex.getMessage()); - } - if (bytes.length > spec.maxResponseBytes) { - return allowLimitFallback ? encodeLimit(spec) : DispatchResult.fatal("response exceeds max_response_bytes"); - } - return DispatchResult.response(bytes); - } - - public static final class DispatchResult { - private final boolean fatal; - private final byte[] envelope; - private final String error; - - private DispatchResult(boolean fatal, byte[] envelope, String error) { - this.fatal = fatal; - this.envelope = envelope == null ? null : envelope.clone(); - this.error = error; - } - - private static DispatchResult response(byte[] envelope) { - return new DispatchResult(false, envelope, null); - } - - private static DispatchResult fatal(String error) { - return new DispatchResult(true, null, error); - } - - public boolean fatal() { - return fatal; - } - - public byte[] envelope() { - return envelope == null ? null : envelope.clone(); - } - - public String error() { - return error; - } - } - - private static final class PointerResult { - private final boolean found; - private final Object value; - - private PointerResult(boolean found, Object value) { - this.found = found; - this.value = value; - } - - private static PointerResult found(Object value) { - return new PointerResult(true, value); - } - - private static PointerResult missing() { - return new PointerResult(false, null); - } - } - - private static final class FunctionSpec { - private final int fnId; - private final int arity; - private final int maxRequestBytes; - private final int maxResponseBytes; - private final int argUtf8Max; - - private FunctionSpec(int fnId, int arity, int maxRequestBytes, int maxResponseBytes, int argUtf8Max) { - this.fnId = fnId; - this.arity = arity; - this.maxRequestBytes = maxRequestBytes; - this.maxResponseBytes = maxResponseBytes; - this.argUtf8Max = argUtf8Max; - } - - private static FunctionSpec forFnId(int fnId) { - if (fnId == HostV1Manifest.DOCUMENT_GET_FN_ID || fnId == HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID) { - return new FunctionSpec(fnId, 1, 4096, 262144, 2048); - } - if (fnId == HostV1Manifest.EMIT_FN_ID) { - return new FunctionSpec(fnId, 1, 32768, 64, Integer.MAX_VALUE); - } - return null; - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java deleted file mode 100644 index 35bff91..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceException.java +++ /dev/null @@ -1,11 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -public class BlueQuickJsResourceException extends RuntimeException { - public BlueQuickJsResourceException(String message) { - super(message); - } - - public BlueQuickJsResourceException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java deleted file mode 100644 index 2f1d382..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParser.java +++ /dev/null @@ -1,133 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.math.BigInteger; - -public final class BlueQuickJsResultParser { - private static final String RESULT_PREFIX = "RESULT"; - private static final String ERROR_PREFIX = "ERROR"; - private static final String GAS_MARKER = " GAS remaining="; - private static final String USED_MARKER = " used="; - - private BlueQuickJsResultParser() { - } - - public static ParsedResult parse(String raw) { - if (raw == null) { - throw new BlueQuickJsDeterminismException("VM output must not be null"); - } - String normalized = raw.trim(); - boolean ok; - String withoutKind; - if (normalized.startsWith(RESULT_PREFIX)) { - ok = true; - withoutKind = normalized.substring(RESULT_PREFIX.length()).trim(); - } else if (normalized.startsWith(ERROR_PREFIX)) { - ok = false; - withoutKind = normalized.substring(ERROR_PREFIX.length()).trim(); - } else { - throw new BlueQuickJsDeterminismException("Unexpected VM output prefix: " + normalized); - } - - int gasIndex = withoutKind.lastIndexOf(GAS_MARKER); - if (gasIndex < 0) { - throw new BlueQuickJsDeterminismException("Missing gas trailer in VM output: " + normalized); - } - String payload = withoutKind.substring(0, gasIndex).trim(); - String trailer = withoutKind.substring(gasIndex + GAS_MARKER.length()); - int usedIndex = trailer.lastIndexOf(USED_MARKER); - if (usedIndex < 0) { - throw new BlueQuickJsDeterminismException("Missing used gas trailer in VM output: " + normalized); - } - long gasRemaining = parseGas(trailer.substring(0, usedIndex), "gasRemaining"); - long gasUsed = parseGas(trailer.substring(usedIndex + USED_MARKER.length()), "wasmGasUsed"); - - if (ok) { - return ParsedResult.success(DeterministicValueCodec.decode(hexToBytes(payload)), gasRemaining, gasUsed, raw); - } - return ParsedResult.error(payload, gasRemaining, gasUsed, raw); - } - - private static long parseGas(String value, String field) { - String trimmed = value.trim(); - try { - BigInteger parsed = new BigInteger(trimmed); - if (parsed.signum() < 0 || parsed.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) { - throw new BlueQuickJsDeterminismException(field + " is outside supported range: " + trimmed); - } - return parsed.longValue(); - } catch (NumberFormatException ex) { - throw new BlueQuickJsDeterminismException("Invalid " + field + ": " + trimmed, ex); - } - } - - private static byte[] hexToBytes(String hex) { - if ((hex.length() & 1) != 0) { - throw new BlueQuickJsDeterminismException("result payload hex must have even length"); - } - byte[] bytes = new byte[hex.length() / 2]; - for (int i = 0; i < bytes.length; i++) { - int high = Character.digit(hex.charAt(i * 2), 16); - int low = Character.digit(hex.charAt(i * 2 + 1), 16); - if (high < 0 || low < 0) { - throw new BlueQuickJsDeterminismException("result payload contains non-hex characters"); - } - bytes[i] = (byte) ((high << 4) | low); - } - return bytes; - } - - public static final class ParsedResult { - private final boolean ok; - private final Object value; - private final String errorMessage; - private final long gasRemaining; - private final long wasmGasUsed; - private final String raw; - - private ParsedResult(boolean ok, - Object value, - String errorMessage, - long gasRemaining, - long wasmGasUsed, - String raw) { - this.ok = ok; - this.value = value; - this.errorMessage = errorMessage; - this.gasRemaining = gasRemaining; - this.wasmGasUsed = wasmGasUsed; - this.raw = raw; - } - - private static ParsedResult success(Object value, long gasRemaining, long wasmGasUsed, String raw) { - return new ParsedResult(true, value, null, gasRemaining, wasmGasUsed, raw); - } - - private static ParsedResult error(String errorMessage, long gasRemaining, long wasmGasUsed, String raw) { - return new ParsedResult(false, null, errorMessage, gasRemaining, wasmGasUsed, raw); - } - - public boolean ok() { - return ok; - } - - public Object value() { - return value; - } - - public String errorMessage() { - return errorMessage; - } - - public long gasRemaining() { - return gasRemaining; - } - - public long wasmGasUsed() { - return wasmGasUsed; - } - - public String raw() { - return raw; - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java deleted file mode 100644 index 1fb713a..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; - -public final class BlueQuickJsSourceWrapper { - private static final String PRELUDE = "\n" - + "const __blueDocument = globalThis.document;\n" - + "const document = Object.assign(\n" - + " (pointer = '/') => __blueDocument(pointer),\n" - + " { canonical: (pointer = '/') => __blueDocument.canonical(pointer) },\n" - + ");\n"; - - private BlueQuickJsSourceWrapper() { - } - - public static String wrap(JavaScriptEvaluationRequest request) { - if (request == null) { - throw new IllegalArgumentException("request must not be null"); - } - return wrap(request.code(), request.mode()); - } - - public static String wrap(String code, JavaScriptEvaluationRequest.Mode mode) { - if (code == null) { - throw new IllegalArgumentException("code must not be null"); - } - if (mode == JavaScriptEvaluationRequest.Mode.EXPRESSION) { - return "(() => {\n" + PRELUDE + "\nreturn (" + code + ");\n})()"; - } - if (mode == JavaScriptEvaluationRequest.Mode.BLOCK) { - return "(() => {\n" + PRELUDE + "\n" + code + "\n})()"; - } - return code; - } - - static String raw(String code) { - if (code == null) { - throw new IllegalArgumentException("code must not be null"); - } - return code; - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java deleted file mode 100644 index 1295bd6..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmInstance.java +++ /dev/null @@ -1,212 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import com.dylibso.chicory.runtime.ExportFunction; -import com.dylibso.chicory.runtime.HostFunction; -import com.dylibso.chicory.runtime.ImportValues; -import com.dylibso.chicory.runtime.Instance; -import com.dylibso.chicory.runtime.Memory; -import com.dylibso.chicory.wasm.Parser; -import com.dylibso.chicory.wasm.WasmModule; -import com.dylibso.chicory.wasm.types.FunctionType; -import com.dylibso.chicory.wasm.types.ValType; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collections; - -public final class BlueQuickJsWasmInstance implements AutoCloseable { - private final BlueQuickJsWasmResources resources; - private final BlueQuickJsHostDispatcher dispatcher; - private final Instance instance; - private final Memory memory; - private final ExportFunction malloc; - private final ExportFunction free; - private final ExportFunction qjsDetInit; - private final ExportFunction qjsDetEval; - private final ExportFunction qjsDetFree; - private boolean closed; - - public BlueQuickJsWasmInstance(BlueQuickJsWasmResources resources, - BlueQuickJsHostDispatcher dispatcher) { - if (resources == null) { - throw new IllegalArgumentException("resources must not be null"); - } - if (dispatcher == null) { - throw new IllegalArgumentException("dispatcher must not be null"); - } - this.resources = resources; - this.dispatcher = dispatcher; - WasmModule module = Parser.parse(resources.wasmBytes()); - this.instance = Instance.builder(module) - .withImportValues(importValues()) - .build(); - this.memory = instance.memory(); - this.malloc = instance.export("malloc"); - this.free = instance.export("free"); - this.qjsDetInit = instance.export("qjs_det_init"); - this.qjsDetEval = instance.export("qjs_det_eval"); - this.qjsDetFree = instance.export("qjs_det_free"); - } - - public void initialize(byte[] manifestBytes, - String manifestHash, - byte[] contextBlob, - long gasLimit) { - ensureOpen(); - int manifestPtr = writeBytes(manifestBytes); - int hashPtr = writeCString(manifestHash); - int contextPtr = contextBlob.length == 0 ? 0 : writeBytes(contextBlob); - try { - long[] result = qjsDetInit.apply( - manifestPtr, - manifestBytes.length, - hashPtr, - contextPtr, - contextBlob.length, - gasLimit); - int errorPtr = (int) result[0]; - if (errorPtr != 0) { - String error = readAndFreeCString(errorPtr); - throw new BlueQuickJsDeterminismException("VM init failed: " + error); - } - } finally { - free(manifestPtr); - free(hashPtr); - if (contextPtr != 0) { - free(contextPtr); - } - } - } - - public String eval(String code) { - ensureOpen(); - int codePtr = writeCString(code); - try { - long[] result = qjsDetEval.apply(codePtr); - int resultPtr = (int) result[0]; - if (resultPtr == 0) { - throw new BlueQuickJsDeterminismException("qjs_det_eval returned a null pointer"); - } - return readAndFreeCString(resultPtr); - } finally { - free(codePtr); - } - } - - @Override - public void close() { - if (!closed) { - qjsDetFree.apply(); - closed = true; - } - } - - private ImportValues importValues() { - return ImportValues.builder() - .addFunction(new HostFunction("host", - "host_call", - FunctionType.of( - Arrays.asList(ValType.I32, ValType.I32, ValType.I32, ValType.I32, ValType.I32), - Collections.singletonList(ValType.I32)), - (hostInstance, args) -> new long[]{hostCall(hostInstance, args)})) - .addFunction(new HostFunction("env", - "abort", - FunctionType.of(Collections.emptyList(), Collections.emptyList()), - (hostInstance, args) -> new long[0])) - .addFunction(new HostFunction("env", - "__assert_fail", - FunctionType.of( - Arrays.asList(ValType.I32, ValType.I32, ValType.I32, ValType.I32), - Collections.emptyList()), - (hostInstance, args) -> new long[0])) - .addFunction(new HostFunction("env", - "emscripten_date_now", - FunctionType.of(Collections.emptyList(), Collections.singletonList(ValType.F64)), - (hostInstance, args) -> new long[]{Double.doubleToRawLongBits(0.0d)})) - .addFunction(new HostFunction("env", - "emscripten_resize_heap", - FunctionType.of(Collections.singletonList(ValType.I32), Collections.singletonList(ValType.I32)), - (hostInstance, args) -> new long[]{0L})) - .build(); - } - - private long hostCall(Instance hostInstance, long[] args) { - try { - Memory hostMemory = hostInstance.memory(); - int fnId = (int) args[0]; - int reqPtr = (int) args[1]; - int reqLen = (int) args[2]; - int respPtr = (int) args[3]; - int respCap = (int) args[4]; - if (reqLen < 0 || respCap < 0 || rangesOverlap(reqPtr, reqLen, respPtr, respCap)) { - return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; - } - byte[] request = hostMemory.readBytes(reqPtr, reqLen); - BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(fnId, request); - if (result.fatal()) { - return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; - } - byte[] envelope = result.envelope(); - if (envelope.length > respCap) { - return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; - } - hostMemory.write(respPtr, envelope); - return envelope.length & 0xffffffffL; - } catch (RuntimeException ex) { - return BlueQuickJsHostDispatcher.TRANSPORT_ERROR; - } - } - - private static boolean rangesOverlap(int aOffset, int aLength, int bOffset, int bLength) { - if (aLength == 0 || bLength == 0) { - return false; - } - long aStart = aOffset & 0xffffffffL; - long bStart = bOffset & 0xffffffffL; - long aEnd = aStart + (aLength & 0xffffffffL); - long bEnd = bStart + (bLength & 0xffffffffL); - return aStart < bEnd && bStart < aEnd; - } - - private int writeBytes(byte[] bytes) { - int ptr = malloc(bytes.length); - memory.write(ptr, bytes); - return ptr; - } - - private int writeCString(String value) { - byte[] text = value.getBytes(StandardCharsets.UTF_8); - byte[] bytes = Arrays.copyOf(text, text.length + 1); - int ptr = malloc(bytes.length); - memory.write(ptr, bytes); - return ptr; - } - - private String readAndFreeCString(int ptr) { - try { - return memory.readCString(ptr, StandardCharsets.UTF_8); - } finally { - free(ptr); - } - } - - private int malloc(int size) { - long[] result = malloc.apply(size); - int ptr = (int) result[0]; - if (ptr == 0) { - throw new BlueQuickJsResourceException("malloc returned null for " + size + " bytes"); - } - return ptr; - } - - private void free(int ptr) { - if (ptr != 0) { - free.apply(ptr); - } - } - - private void ensureOpen() { - if (closed) { - throw new BlueQuickJsResourceException("wasm instance is closed"); - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java deleted file mode 100644 index 6f30bc3..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmResources.java +++ /dev/null @@ -1,781 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; - -public final class BlueQuickJsWasmResources { - public static final String CANONICAL_WASM_FILENAME = "quickjs-eval.wasm"; - public static final String METADATA_FILENAME = "quickjs-wasm-build.metadata.json"; - public static final String CLASSPATH_METADATA_FILENAME = "engine-metadata.json"; - - private static final ObjectMapper JSON = new ObjectMapper(); - private static final byte[] WASM_MAGIC = new byte[]{0x00, 0x61, 0x73, 0x6d}; - private static final Set APPROVED_IMPORTS = Collections.unmodifiableSet(new LinkedHashSet( - Arrays.asList( - "env.abort", - "env.__assert_fail", - "env.emscripten_date_now", - "env.emscripten_resize_heap", - "host.host_call"))); - - private final Path blueQuickJsRoot; - private final Path wasmPath; - private final Path metadataPath; - private final byte[] wasmBytes; - private final JsonNode metadata; - private final String engineBuildHash; - private final String abiManifestHash; - private final int gasVersion; - private final String executionProfile; - private final List imports; - private final List exports; - - private BlueQuickJsWasmResources(Path blueQuickJsRoot, - Path wasmPath, - Path metadataPath, - byte[] wasmBytes, - JsonNode metadata, - String engineBuildHash, - String abiManifestHash, - int gasVersion, - String executionProfile, - List imports, - List exports) { - this.blueQuickJsRoot = blueQuickJsRoot; - this.wasmPath = wasmPath; - this.metadataPath = metadataPath; - this.wasmBytes = wasmBytes.clone(); - this.metadata = metadata; - this.engineBuildHash = engineBuildHash; - this.abiManifestHash = abiManifestHash; - this.gasVersion = gasVersion; - this.executionProfile = executionProfile; - this.imports = Collections.unmodifiableList(new ArrayList(imports)); - this.exports = Collections.unmodifiableList(new ArrayList(exports)); - } - - public static BlueQuickJsWasmResources resolve() { - return resolve(BlueQuickJsWasmRuntimeConfig.defaultConfig()); - } - - public static BlueQuickJsWasmResources resolve(BlueQuickJsWasmRuntimeConfig config) { - if (config == null) { - throw new IllegalArgumentException("config must not be null"); - } - if (config.preferClasspathResources()) { - BlueQuickJsWasmResources classpath = resolveClasspath(config); - if (classpath != null) { - return classpath; - } - } - Path root = resolveRoot(config); - Path wasmPath = locateWasm(root); - Path metadataPath = locateMetadata(wasmPath, root); - byte[] wasmBytes = readBytes(wasmPath); - verifyMagic(wasmBytes, wasmPath); - - JsonNode metadata = readJson(metadataPath); - JsonNode selected = selectedVariant(metadata, config); - String metadataWasmFilename = requiredText(selected.at("/wasm/filename"), - "metadata variants." + config.expectedVariant() + "." + config.expectedBuildType() + ".wasm.filename"); - if (!CANONICAL_WASM_FILENAME.equals(metadataWasmFilename)) { - throw new BlueQuickJsDeterminismException("selected wasm is not canonical " + CANONICAL_WASM_FILENAME - + ": " + metadataWasmFilename); - } - String wasmSha256 = sha256Hex(wasmBytes); - String metadataSha256 = requiredHex(selected.at("/wasm/sha256"), "metadata wasm sha256"); - if (!wasmSha256.equals(metadataSha256)) { - throw new BlueQuickJsDeterminismException("wasm sha256 mismatch: metadata=" + metadataSha256 - + ", actual=" + wasmSha256); - } - String engineBuildHash = requiredHex(selected.get("engineBuildHash"), "metadata engineBuildHash"); - if (!wasmSha256.equals(engineBuildHash)) { - throw new BlueQuickJsDeterminismException("engineBuildHash does not match wasm sha256: engine=" - + engineBuildHash + ", actual=" + wasmSha256); - } - String topLevelEngineBuildHash = requiredHex(metadata.get("engineBuildHash"), "metadata top-level engineBuildHash"); - if (!engineBuildHash.equals(topLevelEngineBuildHash)) { - throw new BlueQuickJsDeterminismException("top-level engineBuildHash does not match selected engineBuildHash"); - } - if (config.expectedEngineBuildHash() == null) { - throw new BlueQuickJsDeterminismException("expected engineBuildHash is required for filesystem wasm resources"); - } - if (!engineBuildHash.equals(config.expectedEngineBuildHash())) { - throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" - + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); - } - verifyMetadataShape(metadata); - PinMetadata pins = verifyPinMetadata(metadata, config, true); - WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); - verifyImports(shape.imports); - verifyExports(shape.exports); - return new BlueQuickJsWasmResources(root, - wasmPath, - metadataPath, - wasmBytes, - metadata, - engineBuildHash, - pins.abiManifestHash, - pins.gasVersion, - pins.executionProfile, - shape.imports, - shape.exports); - } - - private static BlueQuickJsWasmResources resolveClasspath(BlueQuickJsWasmRuntimeConfig config) { - String base = "/blue/contract/processor/quickjs/chicory/"; - try (InputStream wasmInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + CANONICAL_WASM_FILENAME); - InputStream metadataInput = BlueQuickJsWasmResources.class.getResourceAsStream(base + CLASSPATH_METADATA_FILENAME)) { - if (wasmInput == null || metadataInput == null) { - return null; - } - byte[] wasmBytes = readAll(wasmInput); - verifyMagic(wasmBytes, Paths.get("classpath:" + base + CANONICAL_WASM_FILENAME)); - JsonNode metadata = JSON.readTree(metadataInput); - JsonNode selected = selectedVariant(metadata, config); - String metadataWasmFilename = requiredText(selected.at("/wasm/filename"), - "metadata variants." + config.expectedVariant() + "." + config.expectedBuildType() + ".wasm.filename"); - if (!CANONICAL_WASM_FILENAME.equals(metadataWasmFilename)) { - throw new BlueQuickJsDeterminismException("selected classpath wasm is not canonical " - + CANONICAL_WASM_FILENAME + ": " + metadataWasmFilename); - } - String wasmSha256 = sha256Hex(wasmBytes); - String metadataSha256 = requiredHex(selected.at("/wasm/sha256"), "metadata wasm sha256"); - String engineBuildHash = requiredHex(selected.get("engineBuildHash"), "metadata engineBuildHash"); - if (!wasmSha256.equals(metadataSha256) || !wasmSha256.equals(engineBuildHash)) { - throw new BlueQuickJsDeterminismException("classpath wasm hash mismatch"); - } - String topLevelEngineBuildHash = requiredHex(metadata.get("engineBuildHash"), "metadata top-level engineBuildHash"); - if (!engineBuildHash.equals(topLevelEngineBuildHash)) { - throw new BlueQuickJsDeterminismException("top-level engineBuildHash does not match selected engineBuildHash"); - } - if (config.expectedEngineBuildHash() != null && !engineBuildHash.equals(config.expectedEngineBuildHash())) { - throw new BlueQuickJsDeterminismException("engineBuildHash mismatch: expected=" - + config.expectedEngineBuildHash() + ", actual=" + engineBuildHash); - } - verifyMetadataShape(metadata); - PinMetadata pins = verifyPinMetadata(metadata, config, true); - WasmModuleShape shape = WasmModuleShape.parse(wasmBytes); - verifyImports(shape.imports); - verifyExports(shape.exports); - return new BlueQuickJsWasmResources(null, null, null, wasmBytes, metadata, - engineBuildHash, pins.abiManifestHash, pins.gasVersion, pins.executionProfile, shape.imports, shape.exports); - } catch (IOException ex) { - throw new BlueQuickJsResourceException("failed to read classpath blue-quickjs resources", ex); - } - } - - private static byte[] readAll(InputStream input) throws IOException { - byte[] buffer = new byte[8192]; - java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream(); - int read; - while ((read = input.read(buffer)) >= 0) { - output.write(buffer, 0, read); - } - return output.toByteArray(); - } - - public Path blueQuickJsRoot() { - return blueQuickJsRoot; - } - - public Path wasmPath() { - return wasmPath; - } - - public Path metadataPath() { - return metadataPath; - } - - public byte[] wasmBytes() { - return wasmBytes.clone(); - } - - public JsonNode metadata() { - return metadata; - } - - public String engineBuildHash() { - return engineBuildHash; - } - - public String abiManifestHash() { - return abiManifestHash; - } - - public int gasVersion() { - return gasVersion; - } - - public String executionProfile() { - return executionProfile; - } - - public List imports() { - return imports; - } - - public List exports() { - return exports; - } - - private static Path resolveRoot(BlueQuickJsWasmRuntimeConfig config) { - Path configured = config.blueQuickJsRoot(); - if (configured == null) { - String property = System.getProperty(BlueQuickJsWasmRuntimeConfig.BLUE_QUICKJS_ROOT_PROPERTY); - if (property != null && !property.trim().isEmpty()) { - configured = Paths.get(property); - } - } - if (configured == null) { - configured = Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs"); - } - Path root = configured.toAbsolutePath().normalize(); - if (!Files.isDirectory(root)) { - throw new BlueQuickJsResourceException("blue-quickjs root not found: " + root); - } - return root; - } - - private static Path locateWasm(Path root) { - List candidates = Arrays.asList( - root.resolve("libs/quickjs-wasm/dist/wasm").resolve(CANONICAL_WASM_FILENAME), - root.resolve("libs/quickjs-wasm-build/dist").resolve(CANONICAL_WASM_FILENAME)); - for (Path candidate : candidates) { - if (Files.isRegularFile(candidate)) { - String name = candidate.getFileName().toString().toLowerCase(Locale.ROOT); - if (name.contains("debug") || name.contains("wasm64")) { - throw new BlueQuickJsDeterminismException("rejected non-release/non-wasm32 artifact: " + candidate); - } - return candidate; - } - } - throw new BlueQuickJsResourceException("canonical wasm32 release artifact missing under " + root); - } - - private static Path locateMetadata(Path wasmPath, Path root) { - List candidates = Arrays.asList( - wasmPath.getParent().resolve(METADATA_FILENAME), - root.resolve("libs/quickjs-wasm-build/dist").resolve(METADATA_FILENAME)); - for (Path candidate : candidates) { - if (Files.isRegularFile(candidate)) { - return candidate; - } - } - throw new BlueQuickJsResourceException("blue-quickjs wasm metadata missing for " + wasmPath); - } - - private static byte[] readBytes(Path path) { - try { - return Files.readAllBytes(path); - } catch (IOException ex) { - throw new BlueQuickJsResourceException("failed to read " + path, ex); - } - } - - private static JsonNode readJson(Path path) { - try { - return JSON.readTree(Files.newBufferedReader(path, StandardCharsets.UTF_8)); - } catch (IOException ex) { - throw new BlueQuickJsResourceException("failed to parse metadata " + path, ex); - } - } - - private static void verifyMagic(byte[] bytes, Path path) { - if (bytes.length < 8) { - throw new BlueQuickJsDeterminismException("wasm artifact is too small: " + path); - } - for (int i = 0; i < WASM_MAGIC.length; i++) { - if (bytes[i] != WASM_MAGIC[i]) { - throw new BlueQuickJsDeterminismException("invalid wasm magic bytes for " + path); - } - } - } - - private static JsonNode selectedVariant(JsonNode metadata, BlueQuickJsWasmRuntimeConfig config) { - JsonNode selected = metadata.path("variants") - .path(config.expectedVariant()) - .path(config.expectedBuildType()); - if (selected.isMissingNode() || selected.isNull()) { - throw new BlueQuickJsDeterminismException("metadata missing " - + config.expectedVariant() + "/" + config.expectedBuildType() + " artifact"); - } - String buildType = requiredText(selected.get("buildType"), "metadata buildType"); - if (!config.expectedBuildType().equals(buildType)) { - throw new BlueQuickJsDeterminismException("buildType mismatch: expected=" - + config.expectedBuildType() + ", actual=" + buildType); - } - if (!"wasm32".equals(config.expectedVariant())) { - throw new BlueQuickJsDeterminismException("only wasm32 is supported: " + config.expectedVariant()); - } - if (!"release".equals(config.expectedBuildType())) { - throw new BlueQuickJsDeterminismException("only release artifacts are supported: " + config.expectedBuildType()); - } - return selected; - } - - private static void verifyMetadataShape(JsonNode metadata) { - JsonNode memory = metadata.path("build").path("memory"); - int initialMemory = requiredInt(memory.get("initial"), "memory.initial"); - int maximumMemory = requiredInt(memory.get("maximum"), "memory.maximum"); - int stackSize = requiredInt(memory.get("stackSize"), "memory.stackSize"); - if (initialMemory <= 0) { - throw new BlueQuickJsDeterminismException("wasm initial memory must be positive"); - } - if (maximumMemory != initialMemory) { - throw new BlueQuickJsDeterminismException("wasm memory must be fixed"); - } - if (stackSize != 1048576) { - throw new BlueQuickJsDeterminismException("unexpected wasm stack size"); - } - if (memory.path("allowGrowth").asBoolean(true)) { - throw new BlueQuickJsDeterminismException("wasm memory growth must be disabled"); - } - JsonNode flags = metadata.path("build").path("determinism").path("flags"); - if (!flags.isArray()) { - throw new BlueQuickJsDeterminismException("metadata determinism flags are missing"); - } - requireFlag(flags, "-sFILESYSTEM=0"); - requireFlag(flags, "-sALLOW_MEMORY_GROWTH=0"); - requireFlag(flags, "-sINITIAL_MEMORY=" + initialMemory); - requireFlag(flags, "-sMAXIMUM_MEMORY=" + maximumMemory); - requireFlag(flags, "-sSTACK_SIZE=" + stackSize); - requireFlag(flags, "-sALLOW_TABLE_GROWTH=0"); - } - - private static void requireFlag(JsonNode flags, String expected) { - for (JsonNode flag : flags) { - if (expected.equals(flag.asText())) { - return; - } - } - throw new BlueQuickJsDeterminismException("metadata missing deterministic flag " + expected); - } - - private static PinMetadata verifyPinMetadata(JsonNode metadata, - BlueQuickJsWasmRuntimeConfig config, - boolean requireMetadataFields) { - int gasVersion; - if (metadata.has("gasVersion")) { - gasVersion = requiredInt(metadata.get("gasVersion"), "gasVersion"); - } else if (requireMetadataFields) { - throw new BlueQuickJsDeterminismException("required metadata integer missing: gasVersion"); - } else { - gasVersion = config.expectedGasVersion(); - } - if (gasVersion != config.expectedGasVersion()) { - throw new BlueQuickJsDeterminismException("gasVersion mismatch: expected=" - + config.expectedGasVersion() + ", actual=" + gasVersion); - } - - String executionProfile; - if (metadata.has("executionProfile")) { - executionProfile = requiredText(metadata.get("executionProfile"), "executionProfile"); - } else if (requireMetadataFields) { - throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: executionProfile"); - } else { - executionProfile = config.expectedExecutionProfile(); - } - if (!config.expectedExecutionProfile().equals(executionProfile)) { - throw new BlueQuickJsDeterminismException("executionProfile mismatch: expected=" - + config.expectedExecutionProfile() + ", actual=" + executionProfile); - } - - String abiManifestHash; - if (metadata.has("abiManifestHash")) { - abiManifestHash = requiredHex(metadata.get("abiManifestHash"), "abiManifestHash"); - } else if (requireMetadataFields) { - throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: abiManifestHash"); - } else { - abiManifestHash = HostV1Manifest.HOST_V1_HASH; - } - String expected = config.expectedAbiManifestHash(); - if (expected == null || expected.trim().isEmpty()) { - throw new BlueQuickJsDeterminismException("expected ABI manifest hash is required"); - } - if (!HostV1Manifest.HOST_V1_HASH.equals(abiManifestHash)) { - throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch in metadata: expected=" - + HostV1Manifest.HOST_V1_HASH + ", actual=" + abiManifestHash); - } - if (!abiManifestHash.equals(expected)) { - throw new BlueQuickJsDeterminismException("ABI manifest hash mismatch: expected=" - + expected + ", actual=" + abiManifestHash); - } - return new PinMetadata(abiManifestHash, gasVersion, executionProfile); - } - - private static void verifyImports(List imports) { - for (WasmImport wasmImport : imports) { - String key = wasmImport.module() + "." + wasmImport.name(); - if (!APPROVED_IMPORTS.contains(key)) { - throw new BlueQuickJsDeterminismException("unsupported wasm import: " + key); - } - String lower = key.toLowerCase(Locale.ROOT); - if (lower.startsWith("wasi_") || lower.contains("random") || lower.contains("clock") - || lower.contains("fd_") || lower.contains("sock")) { - throw new BlueQuickJsDeterminismException("nondeterministic wasm import rejected: " + key); - } - } - } - - private static void verifyExports(List exports) { - Set names = new LinkedHashSet(); - for (WasmExport wasmExport : exports) { - names.add(wasmExport.name()); - } - for (String required : Arrays.asList("memory", "malloc", "free", "qjs_det_init", "qjs_det_eval", - "qjs_det_set_gas_limit", "qjs_det_free")) { - if (!names.contains(required)) { - throw new BlueQuickJsDeterminismException("required wasm export missing: " + required); - } - } - } - - private static String requiredText(JsonNode node, String path) { - if (node == null || !node.isTextual() || node.asText().trim().isEmpty()) { - throw new BlueQuickJsDeterminismException("required metadata field missing or non-text: " + path); - } - return node.asText(); - } - - private static String requiredHex(JsonNode node, String path) { - String value = requiredText(node, path).toLowerCase(Locale.ROOT); - if (value.length() != 64) { - throw new BlueQuickJsDeterminismException(path + " must be a SHA-256 hex string"); - } - for (int i = 0; i < value.length(); i++) { - if (Character.digit(value.charAt(i), 16) < 0) { - throw new BlueQuickJsDeterminismException(path + " contains non-hex characters"); - } - } - return value; - } - - private static int requiredInt(JsonNode node, String path) { - if (node == null || !node.canConvertToInt()) { - throw new BlueQuickJsDeterminismException("required metadata integer missing: " + path); - } - return node.asInt(); - } - - static String sha256Hex(byte[] bytes) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashed = digest.digest(bytes); - StringBuilder hex = new StringBuilder(hashed.length * 2); - for (byte value : hashed) { - hex.append(String.format(Locale.ROOT, "%02x", value & 0xff)); - } - return hex.toString(); - } catch (NoSuchAlgorithmException ex) { - throw new BlueQuickJsResourceException("SHA-256 is unavailable", ex); - } - } - - private static final class PinMetadata { - private final String abiManifestHash; - private final int gasVersion; - private final String executionProfile; - - private PinMetadata(String abiManifestHash, int gasVersion, String executionProfile) { - this.abiManifestHash = abiManifestHash; - this.gasVersion = gasVersion; - this.executionProfile = executionProfile; - } - } - - public static final class WasmImport { - private final String module; - private final String name; - private final String kind; - private final String signature; - - private WasmImport(String module, String name, String kind, String signature) { - this.module = module; - this.name = name; - this.kind = kind; - this.signature = signature; - } - - public String module() { - return module; - } - - public String name() { - return name; - } - - public String kind() { - return kind; - } - - public String signature() { - return signature; - } - } - - public static final class WasmExport { - private final String name; - private final String kind; - private final int index; - - private WasmExport(String name, String kind, int index) { - this.name = name; - this.kind = kind; - this.index = index; - } - - public String name() { - return name; - } - - public String kind() { - return kind; - } - - public int index() { - return index; - } - } - - private static final class WasmModuleShape { - private final List imports; - private final List exports; - - private WasmModuleShape(List imports, List exports) { - this.imports = imports; - this.exports = exports; - } - - private static WasmModuleShape parse(byte[] bytes) { - WasmReader reader = new WasmReader(bytes); - reader.expectHeader(); - List types = new ArrayList(); - List imports = new ArrayList(); - List exports = new ArrayList(); - while (!reader.done()) { - int sectionId = reader.u8(); - int sectionSize = reader.varuint32(); - int sectionEnd = reader.position() + sectionSize; - if (sectionId == 1) { - int count = reader.varuint32(); - for (int i = 0; i < count; i++) { - reader.expectByte(0x60); - int paramCount = reader.varuint32(); - List params = new ArrayList(); - for (int p = 0; p < paramCount; p++) { - params.add(valType(reader.u8())); - } - int resultCount = reader.varuint32(); - List results = new ArrayList(); - for (int r = 0; r < resultCount; r++) { - results.add(valType(reader.u8())); - } - types.add(new FuncType(params, results)); - } - } else if (sectionId == 2) { - int count = reader.varuint32(); - for (int i = 0; i < count; i++) { - String module = reader.name(); - String name = reader.name(); - int kind = reader.u8(); - imports.add(readImport(reader, types, module, name, kind)); - } - } else if (sectionId == 7) { - int count = reader.varuint32(); - for (int i = 0; i < count; i++) { - String name = reader.name(); - int kind = reader.u8(); - int index = reader.varuint32(); - exports.add(new WasmExport(name, externalKind(kind), index)); - } - } - reader.position(sectionEnd); - } - return new WasmModuleShape(imports, exports); - } - - private static WasmImport readImport(WasmReader reader, - List types, - String module, - String name, - int kind) { - if (kind == 0) { - int typeIndex = reader.varuint32(); - String signature = typeIndex >= 0 && typeIndex < types.size() - ? types.get(typeIndex).signature() - : "type[" + typeIndex + "]"; - return new WasmImport(module, name, "func", signature); - } - if (kind == 1) { - reader.u8(); - skipLimits(reader); - return new WasmImport(module, name, "table", ""); - } - if (kind == 2) { - skipLimits(reader); - return new WasmImport(module, name, "memory", ""); - } - if (kind == 3) { - String type = valType(reader.u8()); - int mutable = reader.u8(); - return new WasmImport(module, name, "global", type + " mutable=" + mutable); - } - throw new BlueQuickJsDeterminismException("unsupported wasm import kind: " + kind); - } - - private static void skipLimits(WasmReader reader) { - int flags = reader.u8(); - reader.varuint32(); - if ((flags & 1) != 0) { - reader.varuint32(); - } - } - - private static String valType(int type) { - switch (type) { - case 0x7f: - return "i32"; - case 0x7e: - return "i64"; - case 0x7d: - return "f32"; - case 0x7c: - return "f64"; - default: - return "0x" + Integer.toHexString(type); - } - } - - private static String externalKind(int kind) { - switch (kind) { - case 0: - return "func"; - case 1: - return "table"; - case 2: - return "memory"; - case 3: - return "global"; - default: - return "kind-" + kind; - } - } - } - - private static final class FuncType { - private final List params; - private final List results; - - private FuncType(List params, List results) { - this.params = params; - this.results = results; - } - - private String signature() { - return "(" + join(params) + ") -> " + (results.isEmpty() ? "()" : join(results)); - } - - private static String join(List values) { - if (values.isEmpty()) { - return ""; - } - StringBuilder builder = new StringBuilder(); - for (int i = 0; i < values.size(); i++) { - if (i > 0) { - builder.append(", "); - } - builder.append(values.get(i)); - } - return builder.toString(); - } - } - - private static final class WasmReader { - private final byte[] bytes; - private int position; - - private WasmReader(byte[] bytes) { - this.bytes = bytes; - } - - private void expectHeader() { - if (bytes.length < 8 || bytes[0] != 0x00 || bytes[1] != 0x61 || bytes[2] != 0x73 || bytes[3] != 0x6d) { - throw new BlueQuickJsDeterminismException("invalid wasm header"); - } - position = 8; - } - - private boolean done() { - return position >= bytes.length; - } - - private int position() { - return position; - } - - private void position(int position) { - if (position < 0 || position > bytes.length) { - throw new BlueQuickJsDeterminismException("invalid wasm section size"); - } - this.position = position; - } - - private int u8() { - if (position >= bytes.length) { - throw new BlueQuickJsDeterminismException("unexpected end of wasm"); - } - return bytes[position++] & 0xff; - } - - private void expectByte(int expected) { - int actual = u8(); - if (actual != expected) { - throw new BlueQuickJsDeterminismException("unexpected wasm byte: expected " - + expected + ", actual " + actual); - } - } - - private int varuint32() { - long result = 0L; - int shift = 0; - for (int i = 0; i < 5; i++) { - int value = u8(); - result |= (long) (value & 0x7f) << shift; - if ((value & 0x80) == 0) { - if (result > 0xffffffffL) { - throw new BlueQuickJsDeterminismException("wasm varuint32 overflow"); - } - return (int) result; - } - shift += 7; - } - throw new BlueQuickJsDeterminismException("wasm varuint32 too long"); - } - - private String name() { - int length = varuint32(); - if (length < 0 || length > bytes.length - position) { - throw new BlueQuickJsDeterminismException("invalid wasm name length"); - } - String value = new String(bytes, position, length, StandardCharsets.UTF_8); - position += length; - return value; - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java deleted file mode 100644 index b202bcd..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsWasmRuntimeConfig.java +++ /dev/null @@ -1,141 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.nio.file.Path; - -public final class BlueQuickJsWasmRuntimeConfig { - public static final String BLUE_QUICKJS_ROOT_PROPERTY = "blue.quickjs.root"; - public static final String ENGINE_BUILD_HASH_PROPERTY = "blue.quickjs.engineBuildHash"; - public static final int DEFAULT_GAS_VERSION = 8; - public static final String DEFAULT_EXECUTION_PROFILE = "baseline-v1"; - - private final Path blueQuickJsRoot; - private final String expectedEngineBuildHash; - private final String expectedAbiManifestHash; - private final int expectedGasVersion; - private final String expectedExecutionProfile; - private final String expectedVariant; - private final String expectedBuildType; - private final boolean preferClasspathResources; - - private BlueQuickJsWasmRuntimeConfig(Builder builder) { - this.blueQuickJsRoot = builder.blueQuickJsRoot; - this.expectedEngineBuildHash = normalizeHex(builder.expectedEngineBuildHash); - this.expectedAbiManifestHash = normalizeHex(builder.expectedAbiManifestHash); - this.expectedGasVersion = builder.expectedGasVersion; - this.expectedExecutionProfile = builder.expectedExecutionProfile; - this.expectedVariant = builder.expectedVariant; - this.expectedBuildType = builder.expectedBuildType; - this.preferClasspathResources = builder.preferClasspathResources; - } - - public Path blueQuickJsRoot() { - return blueQuickJsRoot; - } - - public String expectedEngineBuildHash() { - return expectedEngineBuildHash; - } - - public String expectedAbiManifestHash() { - return expectedAbiManifestHash; - } - - public int expectedGasVersion() { - return expectedGasVersion; - } - - public String expectedExecutionProfile() { - return expectedExecutionProfile; - } - - public String expectedVariant() { - return expectedVariant; - } - - public String expectedBuildType() { - return expectedBuildType; - } - - public boolean preferClasspathResources() { - return preferClasspathResources; - } - - public static Builder builder() { - return new Builder(); - } - - public static BlueQuickJsWasmRuntimeConfig defaultConfig() { - return builder().build(); - } - - private static String normalizeHex(String value) { - return value == null || value.trim().isEmpty() ? null : value.trim().toLowerCase(); - } - - public static final class Builder { - private Path blueQuickJsRoot; - private String expectedEngineBuildHash = System.getProperty(ENGINE_BUILD_HASH_PROPERTY); - private String expectedAbiManifestHash = HostV1Manifest.HOST_V1_HASH; - private int expectedGasVersion = DEFAULT_GAS_VERSION; - private String expectedExecutionProfile = DEFAULT_EXECUTION_PROFILE; - private String expectedVariant = "wasm32"; - private String expectedBuildType = "release"; - private boolean preferClasspathResources = true; - - public Builder blueQuickJsRoot(Path blueQuickJsRoot) { - this.blueQuickJsRoot = blueQuickJsRoot; - return this; - } - - public Builder expectedEngineBuildHash(String expectedEngineBuildHash) { - this.expectedEngineBuildHash = expectedEngineBuildHash; - return this; - } - - public Builder expectedAbiManifestHash(String expectedAbiManifestHash) { - this.expectedAbiManifestHash = expectedAbiManifestHash; - return this; - } - - public Builder expectedGasVersion(int expectedGasVersion) { - this.expectedGasVersion = expectedGasVersion; - return this; - } - - public Builder expectedExecutionProfile(String expectedExecutionProfile) { - this.expectedExecutionProfile = expectedExecutionProfile; - return this; - } - - public Builder expectedVariant(String expectedVariant) { - this.expectedVariant = expectedVariant; - return this; - } - - public Builder expectedBuildType(String expectedBuildType) { - this.expectedBuildType = expectedBuildType; - return this; - } - - public Builder preferClasspathResources(boolean preferClasspathResources) { - this.preferClasspathResources = preferClasspathResources; - return this; - } - - public BlueQuickJsWasmRuntimeConfig build() { - if (expectedGasVersion <= 0) { - throw new IllegalArgumentException("expectedGasVersion must be positive"); - } - if (expectedExecutionProfile == null || expectedExecutionProfile.trim().isEmpty()) { - throw new IllegalArgumentException("expectedExecutionProfile must not be blank"); - } - if (expectedVariant == null || expectedVariant.trim().isEmpty()) { - throw new IllegalArgumentException("expectedVariant must not be blank"); - } - if (expectedBuildType == null || expectedBuildType.trim().isEmpty()) { - throw new IllegalArgumentException("expectedBuildType must not be blank"); - } - return new BlueQuickJsWasmRuntimeConfig(this); - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java deleted file mode 100644 index 1410147..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntime.java +++ /dev/null @@ -1,114 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -public final class ChicoryBlueQuickJsRuntime implements JavaScriptRuntime, AutoCloseable { - private final BlueQuickJsWasmRuntimeConfig config; - private BlueQuickJsWasmResources resources; - - public ChicoryBlueQuickJsRuntime() { - this(BlueQuickJsWasmRuntimeConfig.defaultConfig()); - } - - public ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig config) { - if (config == null) { - throw new IllegalArgumentException("config must not be null"); - } - this.config = config; - } - - public static ChicoryBlueQuickJsRuntime fromClasspathDefaults() { - return new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .build()); - } - - @Override - public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { - if (request == null) { - throw new IllegalArgumentException("request must not be null"); - } - long wasmGasLimit = QuickJsGas.toWasmFuel(request.hostGasLimit()); - BlueQuickJsWasmResources resources = resources(); - Map bindings = request.bindings(); - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings); - byte[] contextBlob = DeterministicValueCodec.encode(contextEnvelope(bindings)); - String source = BlueQuickJsSourceWrapper.wrap(request); - - try (BlueQuickJsWasmInstance instance = new BlueQuickJsWasmInstance(resources, dispatcher)) { - instance.initialize(HostV1Manifest.bytes(), HostV1Manifest.HOST_V1_HASH, contextBlob, wasmGasLimit); - BlueQuickJsResultParser.ParsedResult parsed = BlueQuickJsResultParser.parse(instance.eval(source)); - if (!parsed.ok()) { - long hostGasUsed = QuickJsGas.toHostGasUsed(parsed.wasmGasUsed()); - throw new JavaScriptExecutionException(normalizeVmError(parsed.errorMessage()), - parsed.wasmGasUsed(), - hostGasUsed); - } - return new JavaScriptEvaluationResult(parsed.value(), - parsed.wasmGasUsed(), - QuickJsGas.toHostGasUsed(parsed.wasmGasUsed())); - } catch (JavaScriptExecutionException ex) { - throw ex; - } catch (RuntimeException ex) { - throw new JavaScriptExecutionException("Chicory blue-quickjs evaluation failed: " + ex.getMessage(), ex); - } - } - - @Override - public void close() { - // Fresh Wasm instances are used per evaluation for now. - } - - private synchronized BlueQuickJsWasmResources resources() { - if (resources == null) { - resources = BlueQuickJsWasmResources.resolve(config); - } - return resources; - } - - private static Map contextEnvelope(Map bindings) { - Map source = bindings == null ? Collections.emptyMap() : bindings; - Map envelope = new LinkedHashMap(); - envelope.put("event", valueOrNull(source.get("event"))); - envelope.put("eventCanonical", valueOrNull(source.get("eventCanonical"))); - Object steps = source.get("steps"); - envelope.put("steps", steps == null ? Collections.emptyList() : steps); - envelope.put("currentContract", valueOrNull(source.get("currentContract"))); - envelope.put("currentContractCanonical", valueOrNull(source.get("currentContractCanonical"))); - return envelope; - } - - private static Object valueOrNull(Object value) { - return value == null ? null : value; - } - - private static String normalizeVmError(String message) { - if ("OutOfGas: out of gas".equals(message)) { - return "vm-error: out of gas"; - } - if (message != null && message.startsWith("TypeError: ")) { - return "vm-error: " + message.substring("TypeError: ".length()); - } - if (message != null && message.startsWith("SyntaxError: ")) { - return "vm-error: " + message.substring("SyntaxError: ".length()); - } - if (message != null && message.startsWith("Error: ")) { - return "vm-error: " + message.substring("Error: ".length()); - } - if (message != null && message.startsWith("ReferenceError: ")) { - return "vm-error: " + message.substring("ReferenceError: ".length()); - } - if (message != null && message.startsWith("vm-error: ")) { - return message; - } - return "vm-error: " + (message == null ? "unknown error" : message); - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java deleted file mode 100644 index 37f9b6f..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodec.java +++ /dev/null @@ -1,567 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.io.ByteArrayOutputStream; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetEncoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public final class DeterministicValueCodec { - public static final int MAX_DEPTH = 64; - public static final int MAX_ENCODED_BYTES = 5 * 1024 * 1024; - public static final int MAX_STRING_BYTES = 256 * 1024; - public static final int MAX_ARRAY_LENGTH = 65535; - public static final int MAX_MAP_SIZE = 65535; - private static final long MAX_SAFE_INTEGER = 9007199254740991L; - private static final long MIN_SAFE_INTEGER = -9007199254740991L; - - private DeterministicValueCodec() { - } - - public static byte[] encode(Object value) { - LimitedByteArrayOutputStream output = new LimitedByteArrayOutputStream(MAX_ENCODED_BYTES); - encodeValue(value, output, 0); - return output.toByteArray(); - } - - public static Object decode(byte[] bytes) { - if (bytes == null) { - throw new DeterministicValueException("input bytes must not be null"); - } - if (bytes.length > MAX_ENCODED_BYTES) { - throw new DeterministicValueException("encoded value exceeds max bytes: " + bytes.length); - } - Decoder decoder = new Decoder(bytes); - Object value = decoder.decodeValue(0); - if (!decoder.done()) { - throw new DeterministicValueException("trailing bytes after DV value"); - } - return value; - } - - public static Object roundTrip(Object value) { - return decode(encode(value)); - } - - private static void encodeValue(Object value, LimitedByteArrayOutputStream output, int depth) { - if (value == null) { - output.writeByte(0xf6); - return; - } - if (value instanceof Boolean) { - output.writeByte(Boolean.TRUE.equals(value) ? 0xf5 : 0xf4); - return; - } - if (value instanceof String) { - encodeString((String) value, output); - return; - } - if (value instanceof Number) { - encodeNumber((Number) value, output); - return; - } - if (value instanceof Map) { - encodeMap((Map) value, output, depth); - return; - } - if (value instanceof Collection) { - encodeArray(new ArrayList((Collection) value), output, depth); - return; - } - if (value instanceof Object[]) { - encodeArray(Arrays.asList((Object[]) value), output, depth); - return; - } - throw new DeterministicValueException("unsupported DV value type: " + value.getClass().getName()); - } - - private static void encodeNumber(Number number, LimitedByteArrayOutputStream output) { - if (number instanceof Float) { - throw new DeterministicValueException("float32 values are not supported"); - } - if (number instanceof Double) { - double value = number.doubleValue(); - if (!Double.isFinite(value)) { - throw new DeterministicValueException("number must be finite"); - } - if (Double.doubleToRawLongBits(value) == Double.doubleToRawLongBits(-0.0d)) { - throw new DeterministicValueException("negative zero is not canonical DV"); - } - if (isMathematicalInteger(value) && value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER) { - encodeInteger((long) value, output); - return; - } - encodeFloat64(value, output); - return; - } - if (number instanceof BigDecimal) { - BigDecimal decimal = ((BigDecimal) number).stripTrailingZeros(); - if (decimal.scale() <= 0) { - encodeBigInteger(decimal.toBigIntegerExact(), output); - return; - } - double value = decimal.doubleValue(); - if (!Double.isFinite(value)) { - throw new DeterministicValueException("number must be finite"); - } - if (isMathematicalInteger(value)) { - throw new DeterministicValueException("non-integer BigDecimal lost precision as integer"); - } - encodeFloat64(value, output); - return; - } - if (number instanceof BigInteger) { - encodeBigInteger((BigInteger) number, output); - return; - } - encodeInteger(number.longValue(), output); - } - - private static boolean isMathematicalInteger(double value) { - return value == Math.rint(value); - } - - private static void encodeBigInteger(BigInteger integer, LimitedByteArrayOutputStream output) { - if (integer.compareTo(BigInteger.valueOf(MIN_SAFE_INTEGER)) < 0 - || integer.compareTo(BigInteger.valueOf(MAX_SAFE_INTEGER)) > 0) { - throw new DeterministicValueException("integer exceeds safe integer range"); - } - encodeInteger(integer.longValue(), output); - } - - private static void encodeInteger(long value, LimitedByteArrayOutputStream output) { - if (value < MIN_SAFE_INTEGER || value > MAX_SAFE_INTEGER) { - throw new DeterministicValueException("integer exceeds safe integer range"); - } - if (value >= 0) { - writeTypeAndLength(0, value, output); - } else { - writeTypeAndLength(1, -1L - value, output); - } - } - - private static void encodeFloat64(double value, LimitedByteArrayOutputStream output) { - output.writeByte(0xfb); - long bits = Double.doubleToLongBits(value); - for (int i = 7; i >= 0; i--) { - output.writeByte((int) ((bits >>> (i * 8)) & 0xff)); - } - } - - private static void encodeString(String value, LimitedByteArrayOutputStream output) { - byte[] encoded = utf8(value); - if (encoded.length > MAX_STRING_BYTES) { - throw new DeterministicValueException("string exceeds max UTF-8 bytes: " + encoded.length); - } - writeTypeAndLength(3, encoded.length, output); - output.writeBytes(encoded); - } - - private static byte[] utf8(String value) { - CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - try { - ByteBuffer buffer = encoder.encode(java.nio.CharBuffer.wrap(value)); - byte[] bytes = new byte[buffer.remaining()]; - buffer.get(bytes); - return bytes; - } catch (CharacterCodingException ex) { - throw new DeterministicValueException("string is not well-formed UTF-16/UTF-8", ex); - } - } - - private static void encodeArray(List values, LimitedByteArrayOutputStream output, int depth) { - int nextDepth = depth + 1; - if (nextDepth > MAX_DEPTH) { - throw new DeterministicValueException("maximum DV depth exceeded"); - } - if (values.size() > MAX_ARRAY_LENGTH) { - throw new DeterministicValueException("array exceeds max length: " + values.size()); - } - writeTypeAndLength(4, values.size(), output); - for (Object value : values) { - encodeValue(value, output, nextDepth); - } - } - - private static void encodeMap(Map map, LimitedByteArrayOutputStream output, int depth) { - int nextDepth = depth + 1; - if (nextDepth > MAX_DEPTH) { - throw new DeterministicValueException("maximum DV depth exceeded"); - } - if (map.size() > MAX_MAP_SIZE) { - throw new DeterministicValueException("map exceeds max size: " + map.size()); - } - List entries = new ArrayList(); - for (Map.Entry entry : map.entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new DeterministicValueException("map keys must be strings"); - } - String key = (String) entry.getKey(); - entries.add(new MapEntry(key, utf8(key), entry.getValue())); - } - Collections.sort(entries, new Comparator() { - @Override - public int compare(MapEntry left, MapEntry right) { - return compareEncodedKeys(left.encodedKey, right.encodedKey); - } - }); - writeTypeAndLength(5, entries.size(), output); - for (MapEntry entry : entries) { - encodeString(entry.key, output); - encodeValue(entry.value, output, nextDepth); - } - } - - private static int compareEncodedKeys(byte[] leftUtf8, byte[] rightUtf8) { - byte[] left = encodedTextKey(leftUtf8); - byte[] right = encodedTextKey(rightUtf8); - if (left.length != right.length) { - return left.length < right.length ? -1 : 1; - } - for (int i = 0; i < left.length; i++) { - int a = left[i] & 0xff; - int b = right[i] & 0xff; - if (a != b) { - return a < b ? -1 : 1; - } - } - return 0; - } - - private static byte[] encodedTextKey(byte[] utf8) { - LimitedByteArrayOutputStream output = new LimitedByteArrayOutputStream(MAX_STRING_BYTES + 16); - writeTypeAndLength(3, utf8.length, output); - output.writeBytes(utf8); - return output.toByteArray(); - } - - private static void writeTypeAndLength(int majorType, long value, LimitedByteArrayOutputStream output) { - int major = majorType << 5; - if (value < 0) { - throw new DeterministicValueException("negative CBOR length"); - } - if (value <= 23) { - output.writeByte(major | (int) value); - } else if (value <= 0xffL) { - output.writeByte(major | 24); - output.writeByte((int) value); - } else if (value <= 0xffffL) { - output.writeByte(major | 25); - output.writeByte((int) ((value >>> 8) & 0xff)); - output.writeByte((int) (value & 0xff)); - } else if (value <= 0xffffffffL) { - output.writeByte(major | 26); - for (int i = 3; i >= 0; i--) { - output.writeByte((int) ((value >>> (i * 8)) & 0xff)); - } - } else { - output.writeByte(major | 27); - for (int i = 7; i >= 0; i--) { - output.writeByte((int) ((value >>> (i * 8)) & 0xff)); - } - } - } - - private static Number decodeInteger(long value) { - if (value < MIN_SAFE_INTEGER || value > MAX_SAFE_INTEGER) { - throw new DeterministicValueException("integer exceeds safe integer range"); - } - if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { - return Integer.valueOf((int) value); - } - return Long.valueOf(value); - } - - private static final class MapEntry { - private final String key; - private final byte[] encodedKey; - private final Object value; - - private MapEntry(String key, byte[] encodedKey, Object value) { - this.key = key; - this.encodedKey = encodedKey; - this.value = value; - } - } - - private static final class LimitedByteArrayOutputStream { - private final int limit; - private final ByteArrayOutputStream delegate = new ByteArrayOutputStream(); - - private LimitedByteArrayOutputStream(int limit) { - this.limit = limit; - } - - private void writeByte(int value) { - if (delegate.size() + 1 > limit) { - throw new DeterministicValueException("encoded value exceeds max bytes"); - } - delegate.write(value & 0xff); - } - - private void writeBytes(byte[] bytes) { - if (delegate.size() + bytes.length > limit) { - throw new DeterministicValueException("encoded value exceeds max bytes"); - } - delegate.write(bytes, 0, bytes.length); - } - - private byte[] toByteArray() { - return delegate.toByteArray(); - } - } - - private static final class Decoder { - private final byte[] bytes; - private int offset; - - private Decoder(byte[] bytes) { - this.bytes = bytes; - } - - private boolean done() { - return offset == bytes.length; - } - - private Object decodeValue(int depth) { - if (offset >= bytes.length) { - throw new DeterministicValueException("unexpected end of DV value"); - } - int initial = readU8(); - int major = (initial >>> 5) & 0x07; - int additional = initial & 0x1f; - switch (major) { - case 0: - return decodeInteger(readArgument(additional)); - case 1: { - long encoded = readArgument(additional); - if (encoded >= MAX_SAFE_INTEGER) { - throw new DeterministicValueException("negative integer exceeds safe integer range"); - } - return decodeInteger(-1L - encoded); - } - case 2: - throw new DeterministicValueException("byte strings are not valid DV"); - case 3: - return decodeString(additional); - case 4: - return decodeArray(additional, depth); - case 5: - return decodeMap(additional, depth); - case 6: - throw new DeterministicValueException("CBOR tags are not valid DV"); - case 7: - return decodeSimple(additional); - default: - throw new DeterministicValueException("unsupported DV major type"); - } - } - - private Object decodeSimple(int additional) { - if (additional == 20) { - return Boolean.FALSE; - } - if (additional == 21) { - return Boolean.TRUE; - } - if (additional == 22) { - return null; - } - if (additional == 25) { - throw new DeterministicValueException("float16 is not valid DV"); - } - if (additional == 26) { - throw new DeterministicValueException("float32 is not valid DV"); - } - if (additional == 27) { - double value = Double.longBitsToDouble(readUint64Bits()); - if (!Double.isFinite(value)) { - throw new DeterministicValueException("number must be finite"); - } - if (Double.doubleToRawLongBits(value) == Double.doubleToRawLongBits(-0.0d)) { - throw new DeterministicValueException("negative zero is not canonical DV"); - } - if (isMathematicalInteger(value)) { - throw new DeterministicValueException("integer-valued float64 is not canonical DV"); - } - return Double.valueOf(value); - } - if (additional == 31) { - throw new DeterministicValueException("indefinite-length values are not valid DV"); - } - throw new DeterministicValueException("unsupported CBOR simple value"); - } - - private String decodeString(int additional) { - long length = readArgument(additional); - if (length > MAX_STRING_BYTES) { - throw new DeterministicValueException("string exceeds max UTF-8 bytes: " + length); - } - if (length > bytes.length - offset) { - throw new DeterministicValueException("string length exceeds remaining input"); - } - byte[] encoded = Arrays.copyOfRange(bytes, offset, offset + (int) length); - offset += (int) length; - try { - return StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(encoded)) - .toString(); - } catch (CharacterCodingException ex) { - throw new DeterministicValueException("string payload is not valid UTF-8", ex); - } - } - - private List decodeArray(int additional, int depth) { - int nextDepth = depth + 1; - if (nextDepth > MAX_DEPTH) { - throw new DeterministicValueException("maximum DV depth exceeded"); - } - long length = readArgument(additional); - if (length > MAX_ARRAY_LENGTH) { - throw new DeterministicValueException("array exceeds max length: " + length); - } - List values = new ArrayList((int) length); - for (int i = 0; i < length; i++) { - values.add(decodeValue(nextDepth)); - } - return values; - } - - private Map decodeMap(int additional, int depth) { - int nextDepth = depth + 1; - if (nextDepth > MAX_DEPTH) { - throw new DeterministicValueException("maximum DV depth exceeded"); - } - long length = readArgument(additional); - if (length > MAX_MAP_SIZE) { - throw new DeterministicValueException("map exceeds max size: " + length); - } - Map map = new LinkedHashMap(); - byte[] previous = null; - for (int i = 0; i < length; i++) { - int keyStart = offset; - Object key = decodeValue(nextDepth); - if (!(key instanceof String)) { - throw new DeterministicValueException("map keys must be strings"); - } - byte[] encodedKey = Arrays.copyOfRange(bytes, keyStart, offset); - if (previous != null && compareCanonicalKeys(previous, encodedKey) >= 0) { - throw new DeterministicValueException("map keys must be unique and sorted canonically"); - } - previous = encodedKey; - Object value = decodeValue(nextDepth); - map.put((String) key, value); - } - return map; - } - - private int compareCanonicalKeys(byte[] left, byte[] right) { - if (left.length != right.length) { - return left.length < right.length ? -1 : 1; - } - for (int i = 0; i < left.length; i++) { - int a = left[i] & 0xff; - int b = right[i] & 0xff; - if (a != b) { - return a < b ? -1 : 1; - } - } - return 0; - } - - private long readArgument(int additional) { - if (additional < 24) { - return additional; - } - if (additional == 24) { - int value = readU8(); - if (value < 24) { - throw new DeterministicValueException("non-canonical integer/length width"); - } - return value; - } - if (additional == 25) { - long value = readUnsigned(2); - if (value <= 0xffL) { - throw new DeterministicValueException("non-canonical integer/length width"); - } - return value; - } - if (additional == 26) { - long value = readUnsigned(4); - if (value <= 0xffffL) { - throw new DeterministicValueException("non-canonical integer/length width"); - } - return value; - } - if (additional == 27) { - long value = readUnsigned(8); - if (value <= 0xffffffffL) { - throw new DeterministicValueException("non-canonical integer/length width"); - } - return value; - } - if (additional == 31) { - throw new DeterministicValueException("indefinite-length values are not valid DV"); - } - throw new DeterministicValueException("unsupported CBOR additional information"); - } - - private long readUnsigned(int count) { - if (count > bytes.length - offset) { - throw new DeterministicValueException("unexpected end of DV value"); - } - long value = 0L; - for (int i = 0; i < count; i++) { - value = (value << 8) | readU8(); - } - if (count == 8 && value < 0) { - throw new DeterministicValueException("uint64 value exceeds Java signed range"); - } - return value; - } - - private long readUint64Bits() { - if (8 > bytes.length - offset) { - throw new DeterministicValueException("unexpected end of DV float64"); - } - long value = 0L; - for (int i = 0; i < 8; i++) { - value = (value << 8) | readU8(); - } - return value; - } - - private int readU8() { - if (offset >= bytes.length) { - throw new DeterministicValueException("unexpected end of DV value"); - } - return bytes[offset++] & 0xff; - } - } - - public static final class DeterministicValueException extends BlueQuickJsDeterminismException { - public DeterministicValueException(String message) { - super(message); - } - - public DeterministicValueException(String message, Throwable cause) { - super(message, cause); - } - } -} diff --git a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java b/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java deleted file mode 100644 index d37e4d3..0000000 --- a/quickjs-chicory/src/main/java/blue/contract/processor/conversation/javascript/chicory/HostV1Manifest.java +++ /dev/null @@ -1,37 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -public final class HostV1Manifest { - public static final String ABI_ID = "Host.v1"; - public static final int ABI_VERSION = 1; - public static final String HOST_V1_HASH = - "e23b0b2ee169900bbde7aff78e6ce20fead1715c60f8a8e3106d9959450a3d34"; - public static final String HOST_V1_BYTES_HEX = - "a3666162695f696467486f73742e76316966756e6374696f6e7383a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696401666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e74636765746a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736514676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573016b7363686564756c655f69646b646f632d726561642d76316561726974790165666e5f696402666566666563746452454144666c696d697473a4696d61785f756e6974731903e86c6172675f757466385f6d617881190800716d61785f726571756573745f6279746573191000726d61785f726573706f6e73655f62797465731a00040000676a735f706174688268646f63756d656e746c67657443616e6f6e6963616c6a6172675f736368656d6181a1647479706566737472696e676b6572726f725f636f64657383a26374616771686f73742f696e76616c69645f7061746864636f64656c494e56414c49445f50415448a2637461676a686f73742f6c696d697464636f64656e4c494d49545f4558434545444544a2637461676e686f73742f6e6f745f666f756e6464636f6465694e4f545f464f554e446d72657475726e5f736368656d61a16474797065626476a963676173a5646261736505676b5f756e697473016b6b5f6172675f6279746573016b6b5f7265745f6279746573006b7363686564756c655f696467656d69742d76316561726974790165666e5f6964036665666665637464454d4954666c696d697473a3696d61785f756e697473190400716d61785f726571756573745f6279746573198000726d61785f726573706f6e73655f62797465731840676a735f706174688164656d69746a6172675f736368656d6181a164747970656264766b6572726f725f636f64657381a2637461676a686f73742f6c696d697464636f64656e4c494d49545f45584345454445446d72657475726e5f736368656d61a16474797065646e756c6c6b6162695f76657273696f6e01"; - - public static final int DOCUMENT_GET_FN_ID = 1; - public static final int DOCUMENT_GET_CANONICAL_FN_ID = 2; - public static final int EMIT_FN_ID = 3; - - private HostV1Manifest() { - } - - public static byte[] bytes() { - return hexToBytes(HOST_V1_BYTES_HEX); - } - - private static byte[] hexToBytes(String hex) { - if ((hex.length() & 1) != 0) { - throw new IllegalArgumentException("hex string must have even length"); - } - byte[] bytes = new byte[hex.length() / 2]; - for (int i = 0; i < bytes.length; i++) { - int high = Character.digit(hex.charAt(i * 2), 16); - int low = Character.digit(hex.charAt(i * 2 + 1), 16); - if (high < 0 || low < 0) { - throw new IllegalArgumentException("invalid hex at byte " + i); - } - bytes[i] = (byte) ((high << 4) | low); - } - return bytes; - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java deleted file mode 100644 index 97ae8c2..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResourceIntegrityTest.java +++ /dev/null @@ -1,357 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.HashSet; -import java.util.Set; -import org.junit.jupiter.api.io.TempDir; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class BlueQuickJsResourceIntegrityTest { - private static final ObjectMapper JSON = new ObjectMapper(); - - @Test - void canonicalWasmResourceIsPresentAndPinned(@TempDir Path tempDir) throws IOException { - Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); - BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .expectedGasVersion(ChicoryTestSupport.gasVersion(root)) - .build()); - - assertTrue(Files.isRegularFile(resources.wasmPath())); - assertTrue(resources.wasmPath().getFileName().toString().equals(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME)); - assertEquals(ChicoryTestSupport.engineBuildHash(root), resources.engineBuildHash()); - assertEquals(HostV1Manifest.HOST_V1_HASH, resources.abiManifestHash()); - assertEquals(ChicoryTestSupport.gasVersion(root), resources.gasVersion()); - assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE, resources.executionProfile()); - assertEquals("wasm32", resources.metadata().path("variants").fieldNames().next()); - assertEquals("release", resources.metadata().path("variants").path("wasm32").path("release").path("buildType").asText()); - assertEquals("quickjs-eval.wasm", - resources.metadata().path("variants").path("wasm32").path("release").path("wasm").path("filename").asText()); - } - - @Test - void classpathBundledResourceMetadataIsSelfPinned() { - BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .build()); - - assertEquals(HostV1Manifest.HOST_V1_HASH, resources.abiManifestHash()); - assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION, resources.gasVersion()); - assertEquals(BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE, resources.executionProfile()); - assertEquals(resources.metadata().path("engineBuildHash").asText(), resources.engineBuildHash()); - } - - @Test - void importsContainOnlyApprovedDeterministicSurface(@TempDir Path tempDir) throws IOException { - Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); - BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build()); - Set imports = new HashSet(); - for (BlueQuickJsWasmResources.WasmImport wasmImport : resources.imports()) { - imports.add(wasmImport.module() + "." + wasmImport.name()); - String lower = (wasmImport.module() + "." + wasmImport.name()).toLowerCase(); - assertFalse(lower.contains("random")); - assertFalse(lower.contains("clock")); - assertFalse(lower.contains("fd_")); - assertFalse(lower.contains("sock")); - assertFalse(lower.startsWith("wasi_")); - } - - assertTrue(imports.contains("host.host_call")); - assertTrue(imports.contains("env.abort")); - assertTrue(imports.contains("env.__assert_fail")); - assertTrue(imports.contains("env.emscripten_date_now")); - assertTrue(imports.contains("env.emscripten_resize_heap")); - assertEquals(5, imports.size()); - } - - @Test - void requiredExportsArePresent(@TempDir Path tempDir) throws IOException { - Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); - BlueQuickJsWasmResources resources = BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build()); - Set exports = new HashSet(); - for (BlueQuickJsWasmResources.WasmExport wasmExport : resources.exports()) { - exports.add(wasmExport.name()); - } - - assertTrue(exports.contains("memory")); - assertTrue(exports.contains("malloc")); - assertTrue(exports.contains("free")); - assertTrue(exports.contains("qjs_det_init")); - assertTrue(exports.contains("qjs_det_eval")); - assertTrue(exports.contains("qjs_det_set_gas_limit")); - assertTrue(exports.contains("qjs_det_free")); - } - - @Test - void missingExpectedEngineHashForFilesystemRuntimeFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .build())); - - assertTrue(ex.getMessage().contains("expected engineBuildHash is required")); - } - - @Test - void wrongExpectedEngineHashFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = pinnedFilesystemFixture(blueQuickJsRoot(), tempDir); - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash("0000000000000000000000000000000000000000000000000000000000000000") - .build())); - - assertTrue(ex.getMessage().contains("engineBuildHash mismatch")); - } - - @Test - void missingFilesystemEngineHashFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("engineBuildHash")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(blueQuickJsRoot())) - .build())); - - assertTrue(ex.getMessage().contains("engineBuildHash")); - } - - @Test - void missingFilesystemGasVersionFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("gasVersion")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("gasVersion")); - } - - @Test - void missingFilesystemExecutionProfileFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("executionProfile")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("executionProfile")); - } - - @Test - void missingFilesystemAbiManifestHashFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> metadata.remove("abiManifestHash")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("abiManifestHash")); - } - - @Test - void wrongFilesystemExecutionProfileFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, - metadata -> metadata.put("executionProfile", "different-profile")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("executionProfile mismatch")); - } - - @Test - void filesystemWasmHashMismatchFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, metadata -> ((ObjectNode) metadata - .path("variants") - .path("wasm32") - .path("release") - .path("wasm")) - .put("sha256", "0000000000000000000000000000000000000000000000000000000000000000")); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("wasm sha256 mismatch")); - } - - @Test - void wrongHostV1HashFailsClosed() { - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .expectedAbiManifestHash("0000000000000000000000000000000000000000000000000000000000000000") - .build())); - - assertTrue(ex.getMessage().contains("ABI manifest hash mismatch")); - } - - @Test - void wrongGasVersionFailsClosed() { - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .expectedGasVersion(BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION + 1) - .build())); - - assertTrue(ex.getMessage().contains("gasVersion mismatch")); - } - - @Test - void wrongExecutionProfileFailsClosed() { - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .expectedExecutionProfile("compat-general-v1") - .build())); - - assertTrue(ex.getMessage().contains("executionProfile mismatch")); - } - - @Test - void growingFilesystemMemoryFailsClosed(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, - metadata -> ((ObjectNode) metadata.path("build").path("memory")).put("allowGrowth", true)); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("memory growth must be disabled")); - } - - @Test - void filesystemMemoryFlagsMustMatchMetadata(@TempDir Path tempDir) throws IOException { - Path root = filesystemFixture(blueQuickJsRoot(), tempDir, - metadata -> ((ObjectNode) metadata.path("build").path("memory")).put("initial", 67108864)); - - BlueQuickJsDeterminismException ex = assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsWasmResources.resolve( - BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .preferClasspathResources(false) - .expectedEngineBuildHash(ChicoryTestSupport.engineBuildHash(root)) - .build())); - - assertTrue(ex.getMessage().contains("wasm memory must be fixed") - || ex.getMessage().contains("metadata missing deterministic flag")); - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for resource integrity tests"); - } - - private static Path pinnedFilesystemFixture(Path sourceRoot, Path tempDir) throws IOException { - return filesystemFixture(sourceRoot, tempDir, metadata -> { - }); - } - - private static Path filesystemFixture(Path sourceRoot, - Path tempDir, - MetadataMutation mutation) throws IOException { - Path fixtureRoot = tempDir.resolve("blue-quickjs"); - Path wasmDir = fixtureRoot.resolve("libs/quickjs-wasm/dist/wasm"); - Files.createDirectories(wasmDir); - Files.copy(sourceWasm(sourceRoot), - wasmDir.resolve(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME), - StandardCopyOption.REPLACE_EXISTING); - - ObjectNode metadata = (ObjectNode) JSON.readTree(sourceMetadata(sourceRoot).toFile()); - if (!metadata.has("gasVersion")) { - metadata.put("gasVersion", BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION); - } - metadata.put("executionProfile", BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE); - metadata.put("abiManifestHash", HostV1Manifest.HOST_V1_HASH); - mutation.mutate(metadata); - JSON.writerWithDefaultPrettyPrinter().writeValue( - wasmDir.resolve(BlueQuickJsWasmResources.METADATA_FILENAME).toFile(), - metadata); - return fixtureRoot; - } - - private static Path sourceWasm(Path root) { - Path wasm = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.CANONICAL_WASM_FILENAME); - assumeTrue(Files.isRegularFile(wasm), "canonical wasm is required for resource integrity tests"); - return wasm; - } - - private static Path sourceMetadata(Path root) { - Path metadata = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); - if (Files.isRegularFile(metadata)) { - return metadata; - } - metadata = root.resolve("libs/quickjs-wasm-build/dist").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); - assumeTrue(Files.isRegularFile(metadata), "wasm metadata is required for resource integrity tests"); - return metadata; - } - - private interface MetadataMutation { - void mutate(ObjectNode metadata); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java deleted file mode 100644 index dd69337..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsResultParserTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class BlueQuickJsResultParserTest { - - @Test - void parsesSuccessfulResultWithGas() { - Map value = new LinkedHashMap(); - value.put("a", 1); - value.put("b", Arrays.asList(Boolean.TRUE, null)); - String payload = hex(DeterministicValueCodec.encode(value)); - - BlueQuickJsResultParser.ParsedResult result = - BlueQuickJsResultParser.parse("RESULT " + payload + " GAS remaining=10 used=5"); - - assertTrue(result.ok()); - assertEquals(value, result.value()); - assertEquals(10L, result.gasRemaining()); - assertEquals(5L, result.wasmGasUsed()); - } - - @Test - void parsesErrorResultWithGas() { - BlueQuickJsResultParser.ParsedResult result = - BlueQuickJsResultParser.parse("ERROR TypeError: boom GAS remaining=0 used=7"); - - assertFalse(result.ok()); - assertEquals("TypeError: boom", result.errorMessage()); - assertEquals(0L, result.gasRemaining()); - assertEquals(7L, result.wasmGasUsed()); - } - - @Test - void rejectsMalformedOutput() { - assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsResultParser.parse("UNKNOWN f6 GAS remaining=1 used=1")); - assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsResultParser.parse("RESULT f6")); - assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsResultParser.parse("RESULT f GAS remaining=1 used=1")); - assertThrows(BlueQuickJsDeterminismException.class, - () -> BlueQuickJsResultParser.parse("RESULT xx GAS remaining=1 used=1")); - } - - private static String hex(byte[] bytes) { - StringBuilder builder = new StringBuilder(bytes.length * 2); - for (byte value : bytes) { - builder.append(String.format("%02x", value & 0xff)); - } - return builder.toString(); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java deleted file mode 100644 index 94dc66a..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/BlueQuickJsSourceWrapperTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class BlueQuickJsSourceWrapperTest { - private static final String PRELUDE = "\n" - + "const __blueDocument = globalThis.document;\n" - + "const document = Object.assign(\n" - + " (pointer = '/') => __blueDocument(pointer),\n" - + " { canonical: (pointer = '/') => __blueDocument.canonical(pointer) },\n" - + ");\n"; - - @Test - void expressionWrapperMatchesEvaluateMjs() { - assertEquals("(() => {\n" + PRELUDE + "\nreturn (counter + 1);\n})()", - BlueQuickJsSourceWrapper.wrap("counter + 1", JavaScriptEvaluationRequest.Mode.EXPRESSION)); - } - - @Test - void blockWrapperMatchesEvaluateMjs() { - assertEquals("(() => {\n" + PRELUDE + "\nconst x = 1; return x;\n})()", - BlueQuickJsSourceWrapper.wrap("const x = 1; return x;", JavaScriptEvaluationRequest.Mode.BLOCK)); - } - - @Test - void rawWrapperLeavesCodeUntouched() { - assertEquals("1 + 1", BlueQuickJsSourceWrapper.raw("1 + 1")); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java deleted file mode 100644 index 23dd862..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBenchmarkReportTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ChicoryBenchmarkReportTest { - private static final ObjectMapper JSON = new ObjectMapper(); - - @Test - @Tag("benchmark") - void writesNodeAndChicoryBenchmarkReportWithoutTimingAssertions() throws IOException { - Path root = ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for benchmark report tests"); - List> results = new ArrayList>(); - - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - BenchmarkCase hundred = benchmarkCase("100 arithmetic iterations", 100, - expression("1 + 2", Collections.emptyMap())); - BenchmarkCase simple = benchmarkCase("1000 simple expressions", 1000, - expression("1 + 2", Collections.emptyMap())); - BenchmarkCase documentRead = benchmarkCase("100 document-read expressions", 100, - expression("document('/counter')", documentBindings())); - - for (BenchmarkCase benchmark : new BenchmarkCase[]{hundred, simple, documentRead}) { - BenchmarkResult nodeResult = run("Node bridge", node, benchmark); - BenchmarkResult chicoryResult = run("Chicory", chicory, benchmark); - assertEquals(nodeResult.minGas, chicoryResult.minGas, benchmark.name + " minGas"); - assertEquals(nodeResult.maxGas, chicoryResult.maxGas, benchmark.name + " maxGas"); - assertEquals(nodeResult.totalGas, chicoryResult.totalGas, benchmark.name + " totalGas"); - results.add(nodeResult.toMap()); - results.add(chicoryResult.toMap()); - } - } - - Path reportPath = ChicoryTestSupport.reportPath("blue-quickjs-chicory-benchmarks.json"); - Files.createDirectories(reportPath.getParent()); - Map report = new LinkedHashMap(); - report.put("engineBuildHash", ChicoryTestSupport.engineBuildHash(root)); - report.put("gasVersion", ChicoryTestSupport.gasVersion(root)); - report.put("results", results); - JSON.writerWithDefaultPrettyPrinter().writeValue(reportPath.toFile(), report); - } - - private static JavaScriptEvaluationRequest expression(String code, Map bindings) { - return new JavaScriptEvaluationRequest(code, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT); - } - - private static BenchmarkCase benchmarkCase(String name, - int iterations, - JavaScriptEvaluationRequest request) { - return new BenchmarkCase(name, iterations, request); - } - - private static BenchmarkResult run(String runtime, - JavaScriptRuntime evaluator, - BenchmarkCase benchmark) { - long started = System.nanoTime(); - long minGas = Long.MAX_VALUE; - long maxGas = 0L; - long totalGas = 0L; - for (int i = 0; i < benchmark.iterations; i++) { - JavaScriptEvaluationResult result = evaluator.evaluate(benchmark.request); - minGas = Math.min(minGas, result.wasmGasUsed()); - maxGas = Math.max(maxGas, result.wasmGasUsed()); - totalGas += result.wasmGasUsed(); - } - long elapsedMillis = (System.nanoTime() - started) / 1000000L; - return new BenchmarkResult(runtime, - benchmark.name, - benchmark.iterations, - elapsedMillis, - minGas, - maxGas, - totalGas); - } - - private static Map documentBindings() { - Map counter = new LinkedHashMap(); - counter.put("value", 6); - Map document = new LinkedHashMap(); - document.put("counter", counter); - Map bindings = new LinkedHashMap(); - bindings.put("document", document); - bindings.put("documentCanonical", document); - bindings.put("documentMetadata", Collections.emptyMap()); - return bindings; - } - - private static final class BenchmarkCase { - private final String name; - private final int iterations; - private final JavaScriptEvaluationRequest request; - - private BenchmarkCase(String name, int iterations, JavaScriptEvaluationRequest request) { - this.name = name; - this.iterations = iterations; - this.request = request; - } - } - - private static final class BenchmarkResult { - private final String runtime; - private final String scenario; - private final int iterations; - private final long elapsedMillis; - private final long minGas; - private final long maxGas; - private final long totalGas; - - private BenchmarkResult(String runtime, - String scenario, - int iterations, - long elapsedMillis, - long minGas, - long maxGas, - long totalGas) { - this.runtime = runtime; - this.scenario = scenario; - this.iterations = iterations; - this.elapsedMillis = elapsedMillis; - this.minGas = minGas; - this.maxGas = maxGas; - this.totalGas = totalGas; - } - - private Map toMap() { - Map map = new LinkedHashMap(); - map.put("runtime", runtime); - map.put("scenario", scenario); - map.put("iterations", iterations); - map.put("elapsedMillis", elapsedMillis); - map.put("minGas", minGas); - map.put("maxGas", maxGas); - map.put("totalGas", totalGas); - map.put("finalBlueId", null); - return map; - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java deleted file mode 100644 index 09fe093..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryBlueQuickJsRuntimeSmokeTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ChicoryBlueQuickJsRuntimeSmokeTest { - private static final int SMOKE_REPETITIONS = 2; - - @Test - void deterministicExpressionsEvaluateWithoutNode() { - BlueQuickJsWasmRuntimeConfig config = ChicoryTestSupport.pinnedConfig(blueQuickJsRoot()); - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(config); - - assertStable(runtime, "1 + 2", 3); - assertStable(runtime, "\"blue\" + \"-quickjs\"", "blue-quickjs"); - - Map object = new LinkedHashMap(); - object.put("a", 1); - object.put("b", Arrays.asList(Boolean.TRUE, null)); - assertStable(runtime, "({ a: 1, b: [true, null] })", object); - assertStable(runtime, "[1, 2, 3].map(x => x + 1)", Arrays.asList(2, 3, 4)); - } - - private static void assertStable(ChicoryBlueQuickJsRuntime runtime, String code, Object expected) { - Long wasmGas = null; - Long hostGas = null; - for (int i = 0; i < SMOKE_REPETITIONS; i++) { - JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( - code, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - Collections.emptyMap(), - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); - assertEquals(expected, result.value(), "value drift at iteration " + i + " for " + code); - if (wasmGas == null) { - wasmGas = result.wasmGasUsed(); - hostGas = result.hostGasUsed(); - } else { - assertEquals(wasmGas.longValue(), result.wasmGasUsed(), "wasm gas drift for " + code); - assertEquals(hostGas.longValue(), result.hostGasUsed(), "host gas drift for " + code); - } - } - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for Chicory smoke tests"); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java deleted file mode 100644 index 54e07ca..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryCounterSnapshotRoundTripStressTest.java +++ /dev/null @@ -1,188 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.BlueDocumentProcessorOptions; -import blue.contract.processor.BlueDocumentProcessors; -import blue.contract.processor.conversation.TimelineProviderSupport; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.model.TypeBlueId; -import blue.language.processor.ChannelCheckpointContext; -import blue.language.processor.ChannelEvaluation; -import blue.language.processor.ChannelEvaluationContext; -import blue.language.processor.ChannelProcessor; -import blue.language.processor.DocumentProcessingResult; -import blue.language.snapshot.ResolvedSnapshot; -import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.Timeline; -import blue.repo.v1_3_0.conversation.TimelineChannel; -import blue.repo.v1_3_0.conversation.TimelineEntry; -import java.math.BigInteger; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class ChicoryCounterSnapshotRoundTripStressTest { - private static final int STRESS_ITERATIONS = 100; - private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-stress-simple-timeline-channel"; - - @Test - @Tag("stress") - void chicoryCounterWorkflowSurvivesCanonicalSnapshotRoundTrips() { - Fixture fixture = configuredFixture(); - DocumentProcessingResult initialized = fixture.blue.initializeDocument( - fixture.blue.preprocess(counterDocument(fixture.repository))); - ResolvedSnapshot currentSnapshot = initialized.snapshot(); - assertNotNull(currentSnapshot); - - long totalGas = 0L; - long minGas = Long.MAX_VALUE; - long maxGas = 0L; - String finalBlueId = null; - - for (int i = 1; i <= STRESS_ITERATIONS; i++) { - Node event = timelineEntry(fixture.blue, fixture.repository, "counter", i, chatMessage("tick " + i)); - - DocumentProcessingResult result = fixture.blue.processDocument(currentSnapshot, event); - - assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); - assertNotNull(result.blueId(), "iteration " + i + " should return a BlueId"); - assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); - assertTrue(result.totalGas() > 0, "iteration " + i + " should charge gas"); - - totalGas += result.totalGas(); - minGas = Math.min(minGas, result.totalGas()); - maxGas = Math.max(maxGas, result.totalGas()); - finalBlueId = result.blueId(); - - String canonicalJson = fixture.blue.nodeToJson(result.canonicalDocument()); - Node parsedCanonical = fixture.blue.jsonToNode(canonicalJson); - ResolvedSnapshot loadedSnapshot = fixture.blue.loadSnapshot(parsedCanonical); - assertEquals(result.blueId(), loadedSnapshot.blueId(), "iteration " + i + " should preserve BlueId"); - currentSnapshot = loadedSnapshot; - } - - assertEquals(BigInteger.valueOf(STRESS_ITERATIONS), currentSnapshot.resolvedNodeAt("/counter").getValue()); - assertNotNull(finalBlueId); - assertTrue(totalGas > 0); - assertEquals(minGas, maxGas, "equivalent Chicory increments should charge stable gas"); - } - - private static Fixture configuredFixture() { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(blueQuickJsRoot())); - BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() - .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) - .build()); - blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); - return new Fixture(repository, blue); - } - - private static Node counterDocument(BlueRepository repository) { - Map contracts = new LinkedHashMap(); - contracts.put("owner", channel("counter")); - contracts.put("increment", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("owner")) - .properties("steps", new Node().items(Arrays.asList( - updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 1}")))))); - - return new Node() - .blue(repository.typeAliasBlue()) - .name("Chicory Stress Counter") - .properties("counter", new Node().value(0)) - .properties("contracts", new Node().properties(contracts)); - } - - private static Node updateDocumentStep(String op, String path, Node value) { - return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); - } - - private static Node channel(String timelineId) { - return new Node() - .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) - .properties("timelineId", new Node().value(timelineId)); - } - - private static Node timelineEntry(Blue blue, - BlueRepository repository, - String timelineId, - int timestamp, - Node message) { - TimelineEntry entry = new TimelineEntry() - .timeline(new Timeline().timelineId(timelineId)) - .timestamp(BigInteger.valueOf(timestamp)) - .message(message); - return blue.preprocess(new Node() - .blue(repository.typeAliasBlue()) - .type(TimelineEntry.qualifiedName()) - .properties("timeline", blue.objectToNode(entry.getTimeline())) - .properties("timestamp", new Node().value(entry.getTimestamp())) - .properties("message", entry.getMessage())); - } - - private static Node chatMessage(String message) { - ChatMessage chatMessage = new ChatMessage().message(message); - return new Node() - .type(ChatMessage.qualifiedName()) - .properties("message", new Node().value(chatMessage.getMessage())); - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for Chicory stress tests"); - } - - @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) - public static final class SimpleTimelineChannel extends TimelineChannel { - } - - public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { - @Override - public Class contractType() { - return SimpleTimelineChannel.class; - } - - @Override - public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.evaluateTimelineEntry(contract, context); - } - - @Override - public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.eventId(context.event()); - } - - @Override - public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { - return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); - } - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java deleted file mode 100644 index 67b191d..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryDocumentHostTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; - -class ChicoryDocumentHostTest { - - @Test - void documentGetMatchesEvaluateMjsPointerBehavior() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); - - Object root = call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/"); - assertEquals(Arrays.asList(10, 11), root); - assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/counter")); - assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "counter")); - assertEquals(root, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "")); - assertFatal(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, null); - assertEquals(10, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/items/0")); - assertEquals(1, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~1b")); - assertEquals(2, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/a~0b")); - assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/missing")); - assertFatal(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, 12); - } - - @Test - void documentCanonicalReturnsRawCanonicalNodeAndIgnoresMetadataOverride() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); - - Map canonicalRoot = (Map) call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/"); - Map canonicalCounter = (Map) call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter"); - - assertEquals("Counter", canonicalRoot.get("name")); - assertEquals(6, canonicalCounter.get("value")); - assertEquals("Integer", ((Map) canonicalCounter.get("type")).get("value")); - assertEquals(6, call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter/value")); - } - - @Test - void metadataOverrideOnlyAppliesToNonCanonicalDocumentGet() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); - - assertEquals("Counter label", call(dispatcher, HostV1Manifest.DOCUMENT_GET_FN_ID, "/counter/name")); - assertEquals(null, call(dispatcher, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID, "/counter/name")); - } - - @Test - void emitReturnsDeterministicLimitErrorEnvelope() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindings()); - Map envelope = envelope(dispatcher.dispatch(HostV1Manifest.EMIT_FN_ID, - DeterministicValueCodec.encode(Arrays.asList("event")))); - - assertEquals(0, envelope.get("units")); - assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); - } - - private static Object call(BlueQuickJsHostDispatcher dispatcher, int fnId, Object pointer) { - Map envelope = envelope(dispatcher.dispatch(fnId, DeterministicValueCodec.encode(Arrays.asList(pointer)))); - assertEquals(1, envelope.get("units")); - return envelope.get("ok"); - } - - private static void assertFatal(BlueQuickJsHostDispatcher dispatcher, int fnId, Object pointer) { - BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(fnId, - DeterministicValueCodec.encode(Arrays.asList(pointer))); - org.junit.jupiter.api.Assertions.assertTrue(result.fatal(), "expected fatal transport failure"); - } - - private static Map envelope(BlueQuickJsHostDispatcher.DispatchResult result) { - assertFalse(result.fatal(), result.error()); - return (Map) DeterministicValueCodec.decode(result.envelope()); - } - - private static Map bindings() { - Map counter = new LinkedHashMap(); - counter.put("type", textNode("Integer")); - counter.put("value", 6); - - Map item0 = new LinkedHashMap(); - item0.put("value", 10); - Map item1 = new LinkedHashMap(); - item1.put("value", 11); - - Map document = new LinkedHashMap(); - document.put("name", "Counter"); - document.put("counter", counter); - document.put("items", Arrays.asList(item0, item1)); - document.put("a/b", singletonValue(1)); - document.put("a~b", singletonValue(2)); - - Map metadata = new LinkedHashMap(); - metadata.put("/counter/name", "Counter label"); - - Map bindings = new LinkedHashMap(); - bindings.put("document", document); - bindings.put("documentCanonical", document); - bindings.put("documentMetadata", metadata); - return bindings; - } - - private static Map textNode(String value) { - Map node = new LinkedHashMap(); - node.put("value", value); - return node; - } - - private static Map singletonValue(int value) { - Map node = new LinkedHashMap(); - node.put("value", value); - return node; - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java deleted file mode 100644 index a366b78..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryForbiddenSurfaceTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class ChicoryForbiddenSurfaceTest { - - @Test - void forbiddenSurfaceMatchesNodeOracle() { - Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - assertParity(node, chicory, "typeof Date"); - assertParity(node, chicory, "typeof process"); - assertParity(node, chicory, "typeof require"); - assertParity(node, chicory, "Math.random()"); - assertParity(node, chicory, "eval(\"1\")"); - assertParity(node, chicory, "Function(\"return 1\")()"); - assertParity(node, chicory, "new Proxy({}, {})"); - assertParity(node, chicory, "typeof WeakRef"); - } - } - - private static void assertParity(NodeQuickJsRuntime node, ChicoryBlueQuickJsRuntime chicory, String code) { - ChicoryParityAssertions.assertParity(code, - node, - chicory, - new JavaScriptEvaluationRequest(code, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - Collections.emptyMap(), - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for forbidden surface tests"); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java deleted file mode 100644 index a9f6a12..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryHostCallAbiTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.lang.reflect.Field; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ChicoryHostCallAbiTest { - - @Test - void validDocumentRequestsReturnEnvelopes() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); - - Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, - DeterministicValueCodec.encode(Arrays.asList("/value")))); - - assertEquals("ok", envelope.get("ok")); - assertEquals(1, envelope.get("units")); - } - - @Test - void unknownFunctionAndMalformedRequestAreFatalTransportFailures() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); - - assertTrue(dispatcher.dispatch(999, DeterministicValueCodec.encode(Collections.emptyList())).fatal()); - assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, new byte[]{(byte) 0xff}).fatal()); - assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, DeterministicValueCodec.encode("not-array")).fatal()); - assertTrue(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, DeterministicValueCodec.encode(Arrays.asList((Object) null))).fatal()); - } - - @Test - void responseLargerThanManifestLimitBecomesDeterministicLimitEnvelope() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher( - bindingsWithDocumentValue(repeat('x', 300000))); - - Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, - DeterministicValueCodec.encode(Arrays.asList("/value")))); - - assertEquals(0, envelope.get("units")); - assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); - } - - @Test - void requestLargerThanManifestLimitBecomesDeterministicLimitEnvelope() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); - - Map envelope = decode(dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, - DeterministicValueCodec.encode(Arrays.asList(repeat('p', 5000))))); - - assertEquals(0, envelope.get("units")); - assertEquals("LIMIT_EXCEEDED", ((Map) envelope.get("err")).get("code")); - } - - @Test - void reentrantHostCallIsFatalTransportFailure() throws Exception { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(bindingsWithDocumentValue("ok")); - Field inProgress = BlueQuickJsHostDispatcher.class.getDeclaredField("inProgress"); - inProgress.setAccessible(true); - inProgress.set(dispatcher, Boolean.TRUE); - - BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, - DeterministicValueCodec.encode(Arrays.asList("/value"))); - - assertTrue(result.fatal()); - assertTrue(result.error().contains("reentrant")); - } - - @Test - void hostDispatcherNeverThrowsForInternalFailures() { - BlueQuickJsHostDispatcher dispatcher = new BlueQuickJsHostDispatcher(null); - - BlueQuickJsHostDispatcher.DispatchResult result = dispatcher.dispatch(HostV1Manifest.DOCUMENT_GET_FN_ID, null); - - assertTrue(result.fatal()); - assertFalse(result.error().contains("NullPointerException")); - assertFalse(result.error().contains("java.")); - } - - private static Map decode(BlueQuickJsHostDispatcher.DispatchResult result) { - assertFalse(result.fatal(), result.error()); - return (Map) DeterministicValueCodec.decode(result.envelope()); - } - - private static Map bindingsWithDocumentValue(Object value) { - Map document = new LinkedHashMap(); - document.put("value", value); - Map bindings = new LinkedHashMap(); - bindings.put("document", document); - bindings.put("documentCanonical", document); - bindings.put("documentMetadata", Collections.emptyMap()); - return bindings; - } - - private static String repeat(char value, int count) { - char[] chars = new char[count]; - Arrays.fill(chars, value); - return new String(chars); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java deleted file mode 100644 index b01a872..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryOutOfGasTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class ChicoryOutOfGasTest { - - @Test - void outOfGasBoundariesMatchNodeAndDoNotDrift() { - Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - assertRepeatedParity(node, chicory, "hostGasLimit = 0", "1 + 1", 0L, Collections.emptyMap()); - assertRepeatedParity(node, chicory, "hostGasLimit = 1", "1 + 1", 1L, Collections.emptyMap()); - assertRepeatedParity(node, chicory, "while true", "(() => { while (true) {} })()", 1L, Collections.emptyMap()); - assertRepeatedParity(node, chicory, - "large array loop", - "(() => { let sum = 0; for (let i = 0; i < 100000; i++) sum += i; return sum; })()", - 10L, - Collections.emptyMap()); - assertRepeatedParity(node, chicory, - "recursive function", - "(() => { function f(n) { return n === 0 ? 0 : f(n - 1); } return f(1000000); })()", - 1L, - Collections.emptyMap()); - assertRepeatedParity(node, chicory, - "document.get loop", - "(() => { while (true) { document('/counter'); } })()", - 10L, - bindings()); - } - } - - private static void assertRepeatedParity(NodeQuickJsRuntime node, - ChicoryBlueQuickJsRuntime chicory, - String label, - String code, - long hostGasLimit, - Map bindings) { - ChicoryParityAssertions.Evaluation previous = null; - for (int i = 0; i < 5; i++) { - JavaScriptEvaluationRequest request = new JavaScriptEvaluationRequest(code, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - hostGasLimit); - ChicoryParityAssertions.Evaluation nodeResult = ChicoryParityAssertions.evaluate(node, request); - ChicoryParityAssertions.Evaluation chicoryResult = ChicoryParityAssertions.evaluate(chicory, request); - org.junit.jupiter.api.Assertions.assertEquals(nodeResult, chicoryResult, label + " iteration " + i); - if (previous != null) { - org.junit.jupiter.api.Assertions.assertEquals(previous, chicoryResult, label + " Chicory drift at iteration " + i); - } - previous = chicoryResult; - } - } - - private static Map bindings() { - Map document = new LinkedHashMap(); - Map counter = new LinkedHashMap(); - counter.put("value", 1); - document.put("counter", counter); - Map bindings = new LinkedHashMap(); - bindings.put("document", document); - bindings.put("documentCanonical", document); - bindings.put("documentMetadata", Collections.emptyMap()); - bindings.put("steps", Collections.emptyMap()); - return bindings; - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for OOG tests"); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java deleted file mode 100644 index 45040d9..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryParityAssertions.java +++ /dev/null @@ -1,124 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class ChicoryParityAssertions { - private ChicoryParityAssertions() { - } - - static Evaluation evaluate(JavaScriptRuntime runtime, JavaScriptEvaluationRequest request) { - try { - JavaScriptEvaluationResult result = runtime.evaluate(request); - return Evaluation.ok(result.value(), result.wasmGasUsed(), result.hostGasUsed()); - } catch (JavaScriptExecutionException ex) { - return Evaluation.error(normalizeMessage(ex.getMessage()), ex.wasmGasUsed(), ex.hostGasUsed()); - } - } - - static void assertParity(String label, - JavaScriptRuntime node, - JavaScriptRuntime chicory, - JavaScriptEvaluationRequest request) { - Evaluation expected = evaluate(node, request); - Evaluation actual = evaluate(chicory, request); - assertEquals(expected, actual, label + " should match Node oracle"); - } - - private static String normalizeMessage(String message) { - return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); - } - - static final class Evaluation { - private final boolean ok; - private final Object value; - private final String error; - private final long wasmGasUsed; - private final long hostGasUsed; - - private Evaluation(boolean ok, Object value, String error, long wasmGasUsed, long hostGasUsed) { - this.ok = ok; - this.value = value; - this.error = error; - this.wasmGasUsed = wasmGasUsed; - this.hostGasUsed = hostGasUsed; - } - - static Evaluation ok(Object value, long wasmGasUsed, long hostGasUsed) { - return new Evaluation(true, normalize(value), null, wasmGasUsed, hostGasUsed); - } - - static Evaluation error(String error, long wasmGasUsed, long hostGasUsed) { - return new Evaluation(false, null, error, wasmGasUsed, hostGasUsed); - } - - Map toMap() { - Map map = new LinkedHashMap(); - map.put("ok", ok); - map.put("value", value); - map.put("error", error); - map.put("wasmGasUsed", wasmGasUsed); - map.put("hostGasUsed", hostGasUsed); - return map; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof Evaluation)) { - return false; - } - Evaluation that = (Evaluation) other; - return ok == that.ok - && wasmGasUsed == that.wasmGasUsed - && hostGasUsed == that.hostGasUsed - && java.util.Objects.equals(value, that.value) - && java.util.Objects.equals(error, that.error); - } - - @Override - public int hashCode() { - return java.util.Objects.hash(ok, value, error, wasmGasUsed, hostGasUsed); - } - - @Override - public String toString() { - return toMap().toString(); - } - - @SuppressWarnings("unchecked") - private static Object normalize(Object value) { - if (value instanceof Number) { - Number number = (Number) value; - if (number.doubleValue() == Math.rint(number.doubleValue()) - && number.longValue() >= Integer.MIN_VALUE - && number.longValue() <= Integer.MAX_VALUE) { - return Integer.valueOf(number.intValue()); - } - return value; - } - if (value instanceof List) { - List result = new ArrayList(); - for (Object item : (List) value) { - result.add(normalize(item)); - } - return result; - } - if (value instanceof Map) { - Map result = new LinkedHashMap(); - for (Map.Entry entry : ((Map) value).entrySet()) { - result.put(entry.getKey(), normalize(entry.getValue())); - } - return result; - } - return value; - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java deleted file mode 100644 index ee57957..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryProcessorParityTest.java +++ /dev/null @@ -1,455 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.BlueDocumentProcessorOptions; -import blue.contract.processor.BlueDocumentProcessors; -import blue.contract.processor.conversation.TimelineProviderSupport; -import blue.contract.processor.conversation.expression.QuickJsExpressionEvaluator; -import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import blue.contract.processor.conversation.workflow.JavaScriptCodeStepExecutor; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; -import blue.contract.processor.conversation.workflow.TriggerEventStepExecutor; -import blue.contract.processor.conversation.workflow.UpdateDocumentStepExecutor; -import blue.contract.processor.conversation.workflow.WorkflowStepExecutor; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.model.TypeBlueId; -import blue.language.processor.ChannelCheckpointContext; -import blue.language.processor.ChannelEvaluation; -import blue.language.processor.ChannelEvaluationContext; -import blue.language.processor.ChannelProcessor; -import blue.language.processor.DocumentProcessingResult; -import blue.language.processor.ProcessorFatalException; -import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.JavaScriptCode; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.Timeline; -import blue.repo.v1_3_0.conversation.TimelineChannel; -import blue.repo.v1_3_0.conversation.TimelineEntry; -import java.math.BigInteger; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ChicoryProcessorParityTest { - private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-processor-parity-timeline-channel"; - - @Test - void processorSuccessFixturesMatchNodeOutputAndGas() { - Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - List fixtures = Arrays.asList( - new ProcessorFixture("counter increment with JS expression", - workflowDocument(0, - updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 1}")))), - new ProcessorFixture("workflow with JavaScript Code step emitting event", - workflowDocument(3, - javaScriptStep("Emit", "return { events: [{ type: 'Conversation/Chat Message', message: `Counter ${document('/counter')}` }] };"))), - new ProcessorFixture("Trigger Event with template expressions", - workflowDocument(5, - triggerEventStep(chatMessageEvent("Counter is ${document('/counter')}"))))); - - for (ProcessorFixture fixture : fixtures) { - ProcessorRun nodeRun = process(node, fixture); - ProcessorRun chicoryRun = process(chicory, fixture); - assertEquals(nodeRun, chicoryRun, fixture.name); - } - } - } - - @Test - void processorFailureFixturesMatchNodeFatalBehavior() { - Path root = blueQuickJsRoot(); - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - assertFatalParity(node, - chicory, - new ProcessorFixture("JS Code throw new Error", - workflowDocument(0, javaScriptStep("Throw", "throw new Error('boom');"))), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("JS Code out-of-gas", - workflowDocument(0, javaScriptStep("Loop", "while (true) {}")), - null, - 1L), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("Update Document expression throwing", - workflowDocument(0, updateDocumentStep("replace", - "/counter", - new Node().value("${(() => { throw new Error('boom'); })()}")))), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("Update Document expression out-of-gas", - workflowDocument(0, updateDocumentStep("replace", - "/counter", - new Node().value("${(() => { while (true) {} })()}"))), - 1L, - null), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("Trigger Event template expression throwing", - workflowDocument(0, triggerEventStep( - chatMessageEvent("Counter ${JSON.parse('x')}")))), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("deterministic forbidden global failure", - workflowDocument(0, updateDocumentStep("replace", - "/counter", - new Node().value("${Math.random()}")))), - true); - assertFatalParity(node, - chicory, - new ProcessorFixture("malformed host call failure", - workflowDocument(0, updateDocumentStep("replace", - "/counter", - new Node().value("${document(null)}")))), - true); - } - } - - @Test - void bridgeFailureWithoutVmGasDoesNotFabricateGas() { - ProcessorFixture fixture = new ProcessorFixture("bridge setup failure without VM execution", - workflowDocument(0, updateDocumentStep("replace", "/counter", new Node().value("${1 + 2}")))); - - ProcessorFailure noGas = processFailure(new JavaScriptRuntime() { - @Override - public blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult evaluate( - blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest request) { - throw new JavaScriptExecutionException("bridge setup failed before VM execution"); - } - }, fixture); - ProcessorFailure withGas = processFailure(new JavaScriptRuntime() { - @Override - public blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult evaluate( - blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest request) { - throw new JavaScriptExecutionException("VM execution failed", 11L, 37L); - } - }, fixture); - - assertEquals(noGas.totalGas + 37L, withGas.totalGas); - assertTrue(noGas.normalizedMessage.contains("bridge setup failed before VM execution")); - } - - private static void assertFatalParity(JavaScriptRuntime node, - JavaScriptRuntime chicory, - ProcessorFixture fixture, - boolean expectUnchangedDocument) { - ProcessorFailure nodeFailure = processFailure(node, fixture); - ProcessorFailure chicoryFailure = processFailure(chicory, fixture); - - assertEquals(nodeFailure.classification, chicoryFailure.classification, fixture.name); - assertEquals(nodeFailure.normalizedMessage, chicoryFailure.normalizedMessage, fixture.name); - assertEquals(nodeFailure.totalGas, chicoryFailure.totalGas, fixture.name); - assertTrue(nodeFailure.totalGas > 0L, fixture.name + " should charge VM failure gas"); - assertEquals(nodeFailure.partialCanonicalJson, chicoryFailure.partialCanonicalJson, fixture.name); - assertEquals(nodeFailure.partialBlueId, chicoryFailure.partialBlueId, fixture.name); - assertEquals(nodeFailure.events, chicoryFailure.events, fixture.name); - assertTrue(nodeFailure.events.isEmpty(), fixture.name + " should not emit events after fatal failure"); - if (expectUnchangedDocument) { - assertEquals(nodeFailure.initialCounter, nodeFailure.partialCounter, fixture.name); - } - } - - private static ProcessorRun process(JavaScriptRuntime runtime, ProcessorFixture fixture) { - Fixture configured = configuredFixture(runtime, fixture); - DocumentProcessingResult initialized = configured.blue.initializeDocument( - configured.blue.preprocess(fixture.document)); - - DocumentProcessingResult result = configured.blue.processDocument(initialized.document(), - timelineEntry(configured.blue, configured.repository, "owner", 1, chatMessage("run"))); - - return new ProcessorRun( - canonicalJson(configured, result), - result.blueId(), - eventJson(configured, result.triggeredEvents()), - result.totalGas()); - } - - private static ProcessorFailure processFailure(JavaScriptRuntime runtime, ProcessorFixture fixture) { - Fixture configured = configuredFixture(runtime, fixture); - DocumentProcessingResult initialized = configured.blue.initializeDocument( - configured.blue.preprocess(fixture.document)); - - ProcessorFatalException failure = assertThrows(ProcessorFatalException.class, - () -> configured.blue.processDocument(initialized.document(), - timelineEntry(configured.blue, configured.repository, "owner", 1, chatMessage("run")))); - DocumentProcessingResult partial = failure.partialResult(); - assertNotNull(partial, fixture.name + " should expose a partial fatal result"); - - return new ProcessorFailure( - failure.getClass().getName(), - normalize(failure.getMessage()), - failure.totalGas(), - canonicalJson(configured, initialized), - initialized.blueId(), - initialized.document().get("/counter"), - canonicalJson(configured, partial), - partial.blueId(), - partial.document().get("/counter"), - eventJson(configured, partial.triggeredEvents())); - } - - private static Fixture configuredFixture(JavaScriptRuntime runtime, ProcessorFixture fixture) { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessorOptions.Builder options = BlueDocumentProcessorOptions.builder() - .sequentialWorkflowRunner(workflowRunner(runtime, fixture)); - BlueDocumentProcessors.registerWith(blue, options.build()); - blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); - return new Fixture(repository, blue); - } - - private static SequentialWorkflowRunner workflowRunner(JavaScriptRuntime runtime, ProcessorFixture fixture) { - long expressionHostGasLimit = fixture.expressionHostGasLimit != null - ? fixture.expressionHostGasLimit.longValue() - : QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT; - long codeHostGasLimit = fixture.codeHostGasLimit != null - ? fixture.codeHostGasLimit.longValue() - : QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT; - QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(runtime, expressionHostGasLimit); - return new SequentialWorkflowRunner(Arrays.>asList( - new TriggerEventStepExecutor(resolver), - new JavaScriptCodeStepExecutor(runtime, codeHostGasLimit), - new UpdateDocumentStepExecutor(new QuickJsExpressionEvaluator(runtime, expressionHostGasLimit)))); - } - - private static Node workflowDocument(int counter, Node... steps) { - Map contracts = new LinkedHashMap(); - contracts.put("owner", channel("owner")); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("owner")) - .properties("steps", new Node().items(Arrays.asList(steps)))); - - return new Node() - .blue(BlueRepository.v1_3_0().typeAliasBlue()) - .name("Processor Parity Document") - .properties("counter", new Node().value(counter)) - .properties("contracts", new Node().properties(contracts)); - } - - private static Node updateDocumentStep(String op, String path, Node value) { - return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); - } - - private static Node javaScriptStep(String name, String code) { - return new Node() - .name(name) - .type("Conversation/JavaScript Code") - .properties("code", new Node().value(code)); - } - - private static Node triggerEventStep(Node event) { - return new Node() - .type("Conversation/Trigger Event") - .properties("event", event); - } - - private static Node chatMessageEvent(String message) { - return new Node() - .type(ChatMessage.qualifiedName()) - .properties("message", new Node().value(message)); - } - - private static Node channel(String timelineId) { - return new Node() - .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) - .properties("timelineId", new Node().value(timelineId)); - } - - private static Node timelineEntry(Blue blue, - BlueRepository repository, - String timelineId, - int timestamp, - Node message) { - TimelineEntry entry = new TimelineEntry() - .timeline(new Timeline().timelineId(timelineId)) - .timestamp(BigInteger.valueOf(timestamp)) - .message(message); - return blue.preprocess(new Node() - .blue(repository.typeAliasBlue()) - .type(TimelineEntry.qualifiedName()) - .properties("timeline", blue.objectToNode(entry.getTimeline())) - .properties("timestamp", new Node().value(entry.getTimestamp())) - .properties("message", entry.getMessage())); - } - - private static Node chatMessage(String message) { - return new Node() - .type(ChatMessage.qualifiedName()) - .properties("message", new Node().value(message)); - } - - private static List eventJson(Fixture fixture, List events) { - List json = new ArrayList(); - for (Node event : events) { - json.add(fixture.blue.nodeToJson(event)); - } - return json; - } - - private static String canonicalJson(Fixture fixture, DocumentProcessingResult result) { - Node canonical = result.canonicalDocument(); - return fixture.blue.nodeToJson(canonical != null ? canonical : result.document()); - } - - private static String normalize(String message) { - return message == null ? "" : message.replace("Chicory blue-quickjs evaluation failed: ", ""); - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for processor parity tests"); - } - - @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) - public static final class SimpleTimelineChannel extends TimelineChannel { - } - - public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { - @Override - public Class contractType() { - return SimpleTimelineChannel.class; - } - - @Override - public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.evaluateTimelineEntry(contract, context); - } - - @Override - public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.eventId(context.event()); - } - - @Override - public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { - return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); - } - } - - private static final class ProcessorFixture { - private final String name; - private final Node document; - private final Long expressionHostGasLimit; - private final Long codeHostGasLimit; - - private ProcessorFixture(String name, Node document) { - this(name, document, null, null); - } - - private ProcessorFixture(String name, Node document, Long expressionHostGasLimit, Long codeHostGasLimit) { - this.name = name; - this.document = document; - this.expressionHostGasLimit = expressionHostGasLimit; - this.codeHostGasLimit = codeHostGasLimit; - } - } - - private static final class ProcessorFailure { - private final String classification; - private final String normalizedMessage; - private final long totalGas; - private final String initialCanonicalJson; - private final String initialBlueId; - private final Object initialCounter; - private final String partialCanonicalJson; - private final String partialBlueId; - private final Object partialCounter; - private final List events; - - private ProcessorFailure(String classification, - String normalizedMessage, - long totalGas, - String initialCanonicalJson, - String initialBlueId, - Object initialCounter, - String partialCanonicalJson, - String partialBlueId, - Object partialCounter, - List events) { - this.classification = classification; - this.normalizedMessage = normalizedMessage; - this.totalGas = totalGas; - this.initialCanonicalJson = initialCanonicalJson; - this.initialBlueId = initialBlueId; - this.initialCounter = initialCounter; - this.partialCanonicalJson = partialCanonicalJson; - this.partialBlueId = partialBlueId; - this.partialCounter = partialCounter; - this.events = events; - } - } - - private static final class ProcessorRun { - private final String canonicalJson; - private final String blueId; - private final List events; - private final long totalGas; - - private ProcessorRun(String canonicalJson, String blueId, List events, long totalGas) { - this.canonicalJson = canonicalJson; - this.blueId = blueId; - this.events = events; - this.totalGas = totalGas; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof ProcessorRun)) { - return false; - } - ProcessorRun that = (ProcessorRun) other; - return totalGas == that.totalGas - && java.util.Objects.equals(canonicalJson, that.canonicalJson) - && java.util.Objects.equals(blueId, that.blueId) - && java.util.Objects.equals(events, that.events); - } - - @Override - public int hashCode() { - return java.util.Objects.hash(canonicalJson, blueId, events, totalGas); - } - - @Override - public String toString() { - return "ProcessorRun{blueId='" + blueId + "', totalGas=" + totalGas + ", events=" + events + "}"; - } - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java deleted file mode 100644 index 16f0302..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicorySequentialWorkflowExecutionTest.java +++ /dev/null @@ -1,163 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.BlueDocumentProcessorOptions; -import blue.contract.processor.BlueDocumentProcessors; -import blue.contract.processor.conversation.TimelineProviderSupport; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.model.TypeBlueId; -import blue.language.processor.ChannelCheckpointContext; -import blue.language.processor.ChannelEvaluation; -import blue.language.processor.ChannelEvaluationContext; -import blue.language.processor.ChannelProcessor; -import blue.language.processor.DocumentProcessingResult; -import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.Timeline; -import blue.repo.v1_3_0.conversation.TimelineChannel; -import blue.repo.v1_3_0.conversation.TimelineEntry; -import java.math.BigInteger; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -class ChicorySequentialWorkflowExecutionTest { - private static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "chicory-test-simple-timeline-channel"; - - @Test - void sequentialWorkflowRunsWithChicoryRuntimeInjected() { - Fixture fixture = configuredFixture(); - Node initialized = fixture.blue.initializeDocument(fixture.blue.preprocess(workflowDocument(fixture.repository))).document(); - - DocumentProcessingResult result = fixture.blue.processDocument(initialized, - timelineEntry(fixture.blue, fixture.repository, "owner", 1, chatMessage("run"))); - - assertEquals(BigInteger.TEN, result.document().get("/counter")); - } - - private static Fixture configuredFixture() { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(blueQuickJsRoot())); - BlueDocumentProcessors.registerWith(blue, BlueDocumentProcessorOptions.builder() - .sequentialWorkflowRunner(SequentialWorkflowRunner.withJavaScriptRuntime(runtime)) - .build()); - blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); - return new Fixture(repository, blue); - } - - private static Node workflowDocument(BlueRepository repository) { - Map contracts = new LinkedHashMap(); - contracts.put("owner", channel("owner")); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("owner")) - .properties("steps", new Node().items(Arrays.asList( - updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 5}")), - javaScriptStep("Compute", "return { value: document('/counter') * 2 };"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Compute.value}")))))); - - return new Node() - .blue(repository.typeAliasBlue()) - .name("Chicory Workflow Counter") - .properties("counter", new Node().value(0)) - .properties("contracts", new Node().properties(contracts)); - } - - private static Node updateDocumentStep(String op, String path, Node value) { - return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); - } - - private static Node javaScriptStep(String name, String code) { - return new Node() - .name(name) - .type("Conversation/JavaScript Code") - .properties("code", new Node().value(code)); - } - - private static Node channel(String timelineId) { - return new Node() - .type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)) - .properties("timelineId", new Node().value(timelineId)); - } - - private static Node timelineEntry(Blue blue, - BlueRepository repository, - String timelineId, - int timestamp, - Node message) { - TimelineEntry entry = new TimelineEntry() - .timeline(new Timeline().timelineId(timelineId)) - .timestamp(BigInteger.valueOf(timestamp)) - .message(message); - - Node event = new Node() - .blue(repository.typeAliasBlue()) - .type(TimelineEntry.qualifiedName()) - .properties("timeline", blue.objectToNode(entry.getTimeline())) - .properties("timestamp", new Node().value(entry.getTimestamp())) - .properties("message", entry.getMessage()); - return blue.preprocess(event); - } - - private static Node chatMessage(String message) { - ChatMessage chatMessage = new ChatMessage().message(message); - return new Node() - .type(ChatMessage.qualifiedName()) - .properties("message", new Node().value(chatMessage.getMessage())); - } - - private static Path blueQuickJsRoot() { - return ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for workflow tests"); - } - - @TypeBlueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID) - public static final class SimpleTimelineChannel extends TimelineChannel { - } - - public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { - @Override - public Class contractType() { - return SimpleTimelineChannel.class; - } - - @Override - public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.evaluateTimelineEntry(contract, context); - } - - @Override - public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.eventId(context.event()); - } - - @Override - public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { - return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); - } - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java deleted file mode 100644 index 74a03a5..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryTestSupport.java +++ /dev/null @@ -1,73 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; - -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -final class ChicoryTestSupport { - private static final ObjectMapper JSON = new ObjectMapper(); - - private ChicoryTestSupport() { - } - - static Path blueQuickJsRoot(String reason) { - String configured = System.getProperty("blue.quickjs.root"); - Path root; - if (configured == null || configured.trim().isEmpty()) { - Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); - root = cwd.getParent().resolve("blue-quickjs"); - if (!Files.isDirectory(root) && cwd.getParent() != null && cwd.getParent().getParent() != null) { - root = cwd.getParent().getParent().resolve("blue-quickjs"); - } - } else { - root = Paths.get(configured); - } - assumeTrue(Files.isDirectory(root), reason); - return root; - } - - static BlueQuickJsWasmRuntimeConfig pinnedConfig(Path root) { - return BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(root) - .expectedEngineBuildHash(engineBuildHash(root)) - .build(); - } - - static String engineBuildHash(Path root) { - return metadata(root) - .path("variants") - .path("wasm32") - .path("release") - .path("engineBuildHash") - .asText(); - } - - static int gasVersion(Path root) { - JsonNode gasVersion = metadata(root).get("gasVersion"); - return gasVersion != null && gasVersion.isNumber() - ? gasVersion.asInt() - : BlueQuickJsWasmRuntimeConfig.DEFAULT_GAS_VERSION; - } - - static JsonNode metadata(Path root) { - Path metadata = root.resolve("libs/quickjs-wasm/dist/wasm").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); - if (!Files.isRegularFile(metadata)) { - metadata = root.resolve("libs/quickjs-wasm-build/dist").resolve(BlueQuickJsWasmResources.METADATA_FILENAME); - } - assumeTrue(Files.isRegularFile(metadata), "blue-quickjs wasm metadata is required"); - try { - return JSON.readTree(metadata.toFile()); - } catch (IOException ex) { - throw new AssertionError("failed to read blue-quickjs metadata: " + metadata, ex); - } - } - - static Path reportPath(String fileName) { - return Paths.get(System.getProperty("user.dir"), "build/reports", fileName); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java deleted file mode 100644 index 334ccea..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/ChicoryVsNodeParityTest.java +++ /dev/null @@ -1,274 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -class ChicoryVsNodeParityTest { - private static final ObjectMapper JSON = new ObjectMapper(); - private static final List COMPARED_FIELDS = Collections.unmodifiableList( - Arrays.asList("ok", "value", "error", "wasmGasUsed", "hostGasUsed")); - - @Test - void deterministicFixtureSetMatchesNodeOracleIncludingGas() throws IOException { - Path root = ChicoryTestSupport.blueQuickJsRoot("blue-quickjs checkout is required for parity tests"); - List> reportCases = new ArrayList>(); - List> mismatches = new ArrayList>(); - - ChicoryBlueQuickJsRuntime chicory = new ChicoryBlueQuickJsRuntime(ChicoryTestSupport.pinnedConfig(root)); - try (NodeQuickJsRuntime node = new NodeQuickJsRuntime(root)) { - for (Fixture fixture : fixtures()) { - ChicoryParityAssertions.Evaluation nodeResult = ChicoryParityAssertions.evaluate(node, fixture.request); - ChicoryParityAssertions.Evaluation chicoryResult = ChicoryParityAssertions.evaluate(chicory, fixture.request); - Map entry = new LinkedHashMap(); - entry.put("caseName", fixture.name); - entry.put("name", fixture.name); - entry.put("mode", fixture.request.mode().name()); - entry.put("runtimeMode", fixture.request.mode().name().toLowerCase(java.util.Locale.ROOT)); - entry.put("source", summarize(fixture.request.code())); - entry.put("sourceSha256", sha256(fixture.request.code())); - entry.put("bindingsSummary", fixture.bindingsSummary); - entry.put("hostGasLimit", fixture.request.hostGasLimit()); - entry.put("comparedFields", COMPARED_FIELDS); - entry.put("node", nodeResult.toMap()); - entry.put("chicory", chicoryResult.toMap()); - String mismatchReason = mismatchReason(nodeResult, chicoryResult); - entry.put("status", mismatchReason == null ? "passed" : "failed"); - entry.put("finalStatus", mismatchReason == null ? "passed" : "failed"); - entry.put("mismatchReason", mismatchReason); - entry.put("pass", mismatchReason == null); - entry.put("matches", mismatchReason == null); - reportCases.add(entry); - if (mismatchReason != null) { - mismatches.add(entry); - } - } - } - - Path reportPath = writeReport(root, reportCases, mismatches); - assertTrue(mismatches.isEmpty(), "Chicory parity mismatches written to " + reportPath); - } - - private static List fixtures() { - Map bindings = bindings(); - List fixtures = new ArrayList(); - fixtures.add(expression("arithmetic", "1 + 2 * 3", bindings)); - fixtures.add(expression("sequential workflow with Update Document expression", "document('/counter') + 1", bindings)); - fixtures.add(expression("string template behavior", "`counter=${document('/counter')}; request=${event.message.request}`", bindings)); - fixtures.add(expression("event reads", "event.actor.email + ':' + event.message.request", bindings)); - fixtures.add(expression("object return", "({ ok: true, count: document('/counter') })", bindings)); - fixtures.add(expression("list return", "[1, 'two', true, null, document('/counter')]", bindings)); - fixtures.add(expression("nested object return", - "({ outer: { amount: steps.Prepare.amount, items: [document('/counter'), { actor: event.actor.email }] } })", - bindings)); - fixtures.add(expression("document('/path')", "document('/counter')", bindings)); - fixtures.add(expression("document.canonical('/path')", "document.canonical('/counter')", bindings)); - fixtures.add(expression("documentCanonical binding", "documentCanonical.counter.value", bindings)); - fixtures.add(expression("metadata read /counter/name", "document('/counter/name')", bindings)); - fixtures.add(expression("steps binding", "steps.Prepare.amount + steps.Prepare.delta", bindings)); - fixtures.add(expression("currentContract binding", "currentContract.channel + ':' + currentContract.description", bindings)); - fixtures.add(expression("currentContractCanonical binding", - "currentContractCanonical.description.value + ':' + currentContractCanonical.channel.value", - bindings)); - fixtures.add(expression("forbidden global typeof process", "typeof process", bindings)); - fixtures.add(expression("forbidden Math.random", "Math.random()", bindings)); - fixtures.add(expression("disabled Function constructor", "Function('return 1')()", bindings)); - fixtures.add(expression("thrown error", "(() => { throw new Error('boom'); })()", bindings)); - fixtures.add(expression("host-call limit error", "document('/" + repeat('p', 5000) + "')", bindings)); - fixtures.add(expression("unsupported host function", "Host.v1.unknown('/counter')", bindings)); - fixtures.add(expression("malformed host call", "document(null)", bindings)); - fixtures.add(expression("Trigger Event template expression", "`Counter is ${document('/counter')}`", bindings)); - fixtures.add(expression("recursive function", - "(() => { function f(n) { return n === 0 ? 0 : f(n - 1) + 1; } return f(25); })()", - bindings)); - fixtures.add(expression("document-read loop", - "(() => { let total = 0; for (let i = 0; i < 25; i++) total += document('/counter'); return total; })()", - bindings)); - fixtures.add(block("code block returning events", - "return { events: [{ type: 'Conversation/Chat Message', message: `Counter ${document('/counter')}` }] };", - bindings)); - fixtures.add(block("sequential workflow with JavaScript Code", - "return { value: document('/counter') + steps.Prepare.delta };", - bindings)); - fixtures.add(block("code block returning non-event object", - "return { value: document('/counter'), nested: { ok: true } };", - bindings)); - fixtures.add(expression("null return", "null", bindings)); - fixtures.add(expression("large but valid object", - "(() => { const value = {}; for (let i = 0; i < 64; i++) value['k' + i] = i; return value; })()", - bindings)); - fixtures.add(expression("invalid deterministic value return", "NaN", bindings)); - fixtures.add(expression("syntax error category", "const =", bindings)); - fixtures.add(new Fixture("out-of-gas loop", new JavaScriptEvaluationRequest( - "(() => { while (true) {} })()", - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - 1L))); - fixtures.add(new Fixture("out-of-gas document-read loop", new JavaScriptEvaluationRequest( - "(() => { while (true) { document('/counter'); } })()", - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - 10L))); - return fixtures; - } - - private static Fixture expression(String name, String code, Map bindings) { - return new Fixture(name, new JavaScriptEvaluationRequest(code, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); - } - - private static Fixture block(String name, String code, Map bindings) { - return new Fixture(name, new JavaScriptEvaluationRequest(code, - JavaScriptEvaluationRequest.Mode.BLOCK, - bindings, - QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT)); - } - - private static String mismatchReason(ChicoryParityAssertions.Evaluation nodeResult, - ChicoryParityAssertions.Evaluation chicoryResult) { - Map node = nodeResult.toMap(); - Map chicory = chicoryResult.toMap(); - for (String field : COMPARED_FIELDS) { - if (!java.util.Objects.equals(node.get(field), chicory.get(field))) { - return field + " mismatch"; - } - } - return null; - } - - private static Path writeReport(Path root, - List> cases, - List> mismatches) throws IOException { - Map report = new LinkedHashMap(); - report.put("status", mismatches.isEmpty() ? "passed" : "failed"); - report.put("caseCount", cases.size()); - report.put("generatedTimestamp", Instant.now().toString()); - report.put("javaVersion", System.getProperty("java.version")); - report.put("engineBuildHash", ChicoryTestSupport.engineBuildHash(root)); - report.put("gasVersion", ChicoryTestSupport.gasVersion(root)); - report.put("executionProfile", BlueQuickJsWasmRuntimeConfig.DEFAULT_EXECUTION_PROFILE); - report.put("hostV1Hash", HostV1Manifest.HOST_V1_HASH); - report.put("comparedFields", COMPARED_FIELDS); - report.put("cases", cases); - report.put("mismatches", mismatches); - Path reportPath = ChicoryTestSupport.reportPath("blue-quickjs-chicory-parity.json"); - Files.createDirectories(reportPath.getParent()); - JSON.writerWithDefaultPrettyPrinter().writeValue(reportPath.toFile(), report); - return reportPath; - } - - private static String summarize(String source) { - if (source == null) { - return ""; - } - String compact = source.replaceAll("\\s+", " ").trim(); - return compact.length() <= 240 ? compact : compact.substring(0, 237) + "..."; - } - - private static String sha256(String source) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] bytes = digest.digest((source == null ? "" : source).getBytes(StandardCharsets.UTF_8)); - StringBuilder hex = new StringBuilder(bytes.length * 2); - for (byte value : bytes) { - hex.append(String.format("%02x", value & 0xff)); - } - return hex.toString(); - } catch (NoSuchAlgorithmException ex) { - throw new AssertionError("SHA-256 is required", ex); - } - } - - private static Map bindings() { - Map event = new LinkedHashMap(); - event.put("actor", singleton("email", "alice@example.com")); - event.put("message", singleton("request", 7)); - - Map eventCanonical = new LinkedHashMap(); - Map canonicalMessage = new LinkedHashMap(); - canonicalMessage.put("request", singleton("value", 7)); - eventCanonical.put("message", canonicalMessage); - - Map prepare = new LinkedHashMap(); - prepare.put("amount", 5); - prepare.put("delta", 2); - Map steps = new LinkedHashMap(); - steps.put("Prepare", prepare); - - Map currentContract = new LinkedHashMap(); - currentContract.put("channel", "ownerChannel"); - currentContract.put("description", "Demo workflow"); - - Map currentContractCanonical = new LinkedHashMap(); - currentContractCanonical.put("channel", singleton("value", "ownerChannel")); - currentContractCanonical.put("description", singleton("value", "Demo workflow")); - - Map counter = new LinkedHashMap(); - counter.put("name", "Canonical counter name"); - counter.put("type", singleton("value", "Integer")); - counter.put("value", 6); - - Map document = new LinkedHashMap(); - document.put("name", "Counter"); - document.put("counter", counter); - document.put("items", Arrays.asList(singleton("value", 1), singleton("value", 2))); - - Map metadata = new LinkedHashMap(); - metadata.put("/counter/name", "Counter label"); - - Map bindings = new LinkedHashMap(); - bindings.put("event", event); - bindings.put("eventCanonical", eventCanonical); - bindings.put("steps", steps); - bindings.put("currentContract", currentContract); - bindings.put("currentContractCanonical", currentContractCanonical); - bindings.put("document", document); - bindings.put("documentCanonical", document); - bindings.put("documentMetadata", metadata); - return bindings; - } - - private static Map singleton(String key, Object value) { - Map map = new LinkedHashMap(); - map.put(key, value); - return map; - } - - private static String repeat(char value, int count) { - char[] chars = new char[count]; - Arrays.fill(chars, value); - return new String(chars); - } - - private static final class Fixture { - private final String name; - private final JavaScriptEvaluationRequest request; - private final String bindingsSummary; - - private Fixture(String name, JavaScriptEvaluationRequest request) { - this.name = name; - this.request = request; - this.bindingsSummary = "keys=" + request.bindings().keySet() - + ", codeChars=" + request.code().length(); - } - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java deleted file mode 100644 index e3364a6..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/DeterministicValueCodecTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class DeterministicValueCodecTest { - - @Test - void allowedValuesRoundTrip() { - assertRoundTrip(null, null); - assertRoundTrip(Boolean.TRUE, Boolean.TRUE); - assertRoundTrip(Boolean.FALSE, Boolean.FALSE); - assertRoundTrip(0, 0); - assertRoundTrip(1, 1); - assertRoundTrip(-1, -1); - assertRoundTrip(BigInteger.valueOf(9007199254740991L), 9007199254740991L); - assertRoundTrip(BigInteger.valueOf(-9007199254740991L), -9007199254740991L); - assertRoundTrip(1.5d, 1.5d); - assertRoundTrip("hello", "hello"); - assertRoundTrip(Collections.emptyList(), Collections.emptyList()); - assertRoundTrip(Arrays.asList(1, Boolean.TRUE, null), Arrays.asList(1, Boolean.TRUE, null)); - - Map ok = new LinkedHashMap(); - ok.put("ok", Boolean.TRUE); - assertRoundTrip(ok, ok); - - Map canonicalOrder = new LinkedHashMap(); - canonicalOrder.put("b", 2); - canonicalOrder.put("aa", 1); - assertRoundTrip(canonicalOrder, canonicalOrder); - } - - @Test - void goldenEncodingsMatchBlueQuickJsDocs() { - assertHex("f6", null); - assertHex("f5", Boolean.TRUE); - assertHex("20", -1); - assertHex("826568656c6c6ffb3ff8000000000000", Arrays.asList("hello", 1.5d)); - - Map ok = new LinkedHashMap(); - ok.put("ok", Boolean.TRUE); - assertHex("a1626f6bf5", ok); - - Map ordered = new LinkedHashMap(); - ordered.put("b", 2); - ordered.put("aa", 1); - assertHex("a261620262616101", ordered); - } - - @Test - void nestedObjectAtMaxDepthIsAllowed() { - Object value = "leaf"; - for (int i = 0; i < DeterministicValueCodec.MAX_DEPTH; i++) { - value = Collections.singletonList(value); - } - - Object decoded = DeterministicValueCodec.decode(DeterministicValueCodec.encode(value)); - - assertEquals(value, decoded); - } - - @Test - void forbiddenNumbersAreRejected() { - assertEncodeFails(Double.NaN, "finite"); - assertEncodeFails(Double.POSITIVE_INFINITY, "finite"); - assertEncodeFails(Double.NEGATIVE_INFINITY, "finite"); - assertEncodeFails(-0.0d, "negative zero"); - assertEncodeFails(Float.valueOf(1.5f), "float32"); - } - - @Test - void forbiddenCborFormsAreRejected() { - assertDecodeFails("a2616101616102", "unique and sorted"); - assertDecodeFails("a262616101616202", "unique and sorted"); - assertDecodeFails("9fff", "indefinite"); - assertDecodeFails("bfff", "indefinite"); - assertDecodeFails("7fff", "indefinite"); - assertDecodeFails("c0f6", "tags"); - assertDecodeFails("f93c00", "float16"); - assertDecodeFails("fa3f800000", "float32"); - assertDecodeFails("fb7ff8000000000000", "finite"); - assertDecodeFails("fb8000000000000000", "negative zero"); - assertDecodeFails("fb3ff0000000000000", "integer-valued"); - assertDecodeFails("1817", "non-canonical"); - } - - @Test - void limitsAreEnforced() { - assertEncodeFails(repeat('x', DeterministicValueCodec.MAX_STRING_BYTES + 1), "string exceeds"); - - Object tooDeep = "leaf"; - for (int i = 0; i < DeterministicValueCodec.MAX_DEPTH + 1; i++) { - tooDeep = Collections.singletonList(tooDeep); - } - assertEncodeFails(tooDeep, "depth"); - - List tooLongArray = new ArrayList(); - for (int i = 0; i < DeterministicValueCodec.MAX_ARRAY_LENGTH + 1; i++) { - tooLongArray.add(null); - } - assertEncodeFails(tooLongArray, "array exceeds"); - - Map tooLargeMap = new LinkedHashMap(); - for (int i = 0; i < DeterministicValueCodec.MAX_MAP_SIZE + 1; i++) { - tooLargeMap.put("k" + i, i); - } - assertEncodeFails(tooLargeMap, "map exceeds"); - - List tooManyBytes = new ArrayList(); - String eighty = repeat('y', 80); - for (int i = 0; i < DeterministicValueCodec.MAX_ARRAY_LENGTH; i++) { - tooManyBytes.add(eighty); - } - assertEncodeFails(tooManyBytes, "encoded value exceeds"); - } - - @Test - void decodeLimitsAreEnforced() { - assertDecodeFails("7a00040001", "string exceeds"); - assertDecodeFails("9a00010000", "array exceeds"); - assertDecodeFails("ba00010000", "map exceeds"); - } - - private static void assertRoundTrip(Object value, Object expected) { - assertEquals(expected, DeterministicValueCodec.roundTrip(value)); - } - - private static void assertHex(String expectedHex, Object value) { - assertArrayEquals(bytes(expectedHex), DeterministicValueCodec.encode(value)); - assertEquals(DeterministicValueCodec.decode(bytes(expectedHex)), DeterministicValueCodec.roundTrip(value)); - } - - private static void assertEncodeFails(Object value, String messagePart) { - DeterministicValueCodec.DeterministicValueException ex = assertThrows( - DeterministicValueCodec.DeterministicValueException.class, - () -> DeterministicValueCodec.encode(value)); - assertContains(ex, messagePart); - } - - private static void assertDecodeFails(String hex, String messagePart) { - DeterministicValueCodec.DeterministicValueException ex = assertThrows( - DeterministicValueCodec.DeterministicValueException.class, - () -> DeterministicValueCodec.decode(bytes(hex))); - assertContains(ex, messagePart); - } - - private static void assertContains(Exception ex, String messagePart) { - String message = ex.getMessage(); - if (message == null || !message.contains(messagePart)) { - throw new AssertionError("Expected message to contain \"" + messagePart + "\" but was: " + message); - } - } - - private static byte[] bytes(String hex) { - byte[] bytes = new byte[hex.length() / 2]; - for (int i = 0; i < bytes.length; i++) { - bytes[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); - } - return bytes; - } - - private static String repeat(char value, int count) { - char[] chars = new char[count]; - Arrays.fill(chars, value); - return new String(chars); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java deleted file mode 100644 index cc5e789..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/HostV1ManifestTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class HostV1ManifestTest { - - @Test - void embeddedManifestHashMatchesHostV1Hash() { - assertEquals("Host.v1", HostV1Manifest.ABI_ID); - assertEquals(1, HostV1Manifest.ABI_VERSION); - assertEquals(HostV1Manifest.HOST_V1_HASH, - BlueQuickJsWasmResources.sha256Hex(HostV1Manifest.bytes())); - } - - @Test - void functionIdsAreStable() { - assertEquals(1, HostV1Manifest.DOCUMENT_GET_FN_ID); - assertEquals(2, HostV1Manifest.DOCUMENT_GET_CANONICAL_FN_ID); - assertEquals(3, HostV1Manifest.EMIT_FN_ID); - } - - @Test - void reservedTransportErrorsAreNotDeclaredAsBusinessErrors() { - byte[] bytes = HostV1Manifest.bytes(); - String manifestAscii = new String(bytes, StandardCharsets.ISO_8859_1); - - assertFalse(manifestAscii.contains("HOST_TRANSPORT")); - assertFalse(manifestAscii.contains("HOST_ENVELOPE_INVALID")); - assertTrue(manifestAscii.contains("Host.v1")); - assertTrue(manifestAscii.contains("document")); - assertTrue(manifestAscii.contains("getCanonical")); - assertTrue(manifestAscii.contains("emit")); - } -} diff --git a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java b/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java deleted file mode 100644 index 89b09fa..0000000 --- a/quickjs-chicory/src/test/java/blue/contract/processor/conversation/javascript/chicory/LambdaPackagingSmokeTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package blue.contract.processor.conversation.javascript.chicory; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.Collections; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class LambdaPackagingSmokeTest { - - @Test - void classpathPinnedResourcesEvaluateWithoutFilesystemRoot() { - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .build()); - - JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( - "1 + 2", - JavaScriptEvaluationRequest.Mode.EXPRESSION, - Collections.emptyMap(), - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); - - assertEquals(3, result.value()); - } - - @Test - void runtimeClasspathDoesNotContainNativeJavaScriptEngines() { - String classpath = System.getProperty("java.class.path").toLowerCase(); - for (String forbidden : new String[]{"javet", "wasmtime", "quickjs4j", "graal-js", "org.graalvm.js", "jna"}) { - assertFalse(classpath.contains(forbidden), "unexpected runtime dependency on " + forbidden); - } - } - - @Test - void classpathRuntimeWorksWhenNodeIsNotOnPath() throws IOException, InterruptedException { - String java = System.getProperty("java.home") + "/bin/java"; - ProcessBuilder builder = new ProcessBuilder(java, - "-cp", - System.getProperty("java.class.path"), - NoNodeClasspathSmokeMain.class.getName()); - builder.environment().put("PATH", "/bin"); - builder.redirectErrorStream(true); - Process process = builder.start(); - String output = readOutput(process); - int exit = process.waitFor(); - - assertEquals(0, exit, output); - assertTrue(output.contains("nodeUnavailable=true"), output); - assertTrue(output.contains("value=3"), output); - } - - private static String readOutput(Process process) throws IOException { - StringBuilder output = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append('\n'); - } - } - return output.toString(); - } - - public static final class NoNodeClasspathSmokeMain { - public static void main(String[] args) { - boolean nodeUnavailable = nodeUnavailable(); - if (!nodeUnavailable) { - System.out.println("nodeUnavailable=false"); - System.exit(2); - } - ChicoryBlueQuickJsRuntime runtime = new ChicoryBlueQuickJsRuntime(BlueQuickJsWasmRuntimeConfig.builder() - .blueQuickJsRoot(null) - .preferClasspathResources(true) - .build()); - JavaScriptEvaluationResult result = runtime.evaluate(new JavaScriptEvaluationRequest( - "1 + 2", - JavaScriptEvaluationRequest.Mode.EXPRESSION, - Collections.emptyMap(), - QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT)); - System.out.println("nodeUnavailable=true"); - System.out.println("value=" + result.value()); - } - - private static boolean nodeUnavailable() { - try { - Process process = new ProcessBuilder("node", "--version").start(); - process.destroyForcibly(); - return false; - } catch (IOException ex) { - return true; - } - } - } -} diff --git a/settings.gradle b/settings.gradle index d02aed2..aede013 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1 @@ -rootProject.name = 'blue-contract-java' -include 'quickjs-chicory' +rootProject.name = 'blue-coordination-java' diff --git a/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java b/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java deleted file mode 100644 index d1d3722..0000000 --- a/src/main/java/blue/contract/processor/BlueDocumentProcessorOptions.java +++ /dev/null @@ -1,45 +0,0 @@ -package blue.contract.processor; - -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; - -public final class BlueDocumentProcessorOptions { - private final JavaScriptRuntime javaScriptRuntime; - private final SequentialWorkflowRunner sequentialWorkflowRunner; - - private BlueDocumentProcessorOptions(Builder builder) { - this.javaScriptRuntime = builder.javaScriptRuntime; - this.sequentialWorkflowRunner = builder.sequentialWorkflowRunner; - } - - public JavaScriptRuntime javaScriptRuntime() { - return javaScriptRuntime; - } - - public SequentialWorkflowRunner sequentialWorkflowRunner() { - return sequentialWorkflowRunner; - } - - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - private JavaScriptRuntime javaScriptRuntime; - private SequentialWorkflowRunner sequentialWorkflowRunner; - - public Builder javaScriptRuntime(JavaScriptRuntime javaScriptRuntime) { - this.javaScriptRuntime = javaScriptRuntime; - return this; - } - - public Builder sequentialWorkflowRunner(SequentialWorkflowRunner sequentialWorkflowRunner) { - this.sequentialWorkflowRunner = sequentialWorkflowRunner; - return this; - } - - public BlueDocumentProcessorOptions build() { - return new BlueDocumentProcessorOptions(this); - } - } -} diff --git a/src/main/java/blue/contract/processor/BlueDocumentProcessors.java b/src/main/java/blue/contract/processor/BlueDocumentProcessors.java deleted file mode 100644 index d1013ee..0000000 --- a/src/main/java/blue/contract/processor/BlueDocumentProcessors.java +++ /dev/null @@ -1,34 +0,0 @@ -package blue.contract.processor; - -import blue.language.Blue; -import blue.language.processor.DocumentProcessor; - -public final class BlueDocumentProcessors { - private BlueDocumentProcessors() { - } - - public static Blue registerWith(Blue blue) { - return registerWith(blue, null); - } - - public static Blue registerWith(Blue blue, BlueDocumentProcessorOptions options) { - if (blue == null) { - throw new IllegalArgumentException("blue must not be null"); - } - ConversationProcessors.registerWith(blue, options); - MyOSProcessors.registerWith(blue); - return blue; - } - - public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder) { - return configure(builder, null); - } - - public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder, - BlueDocumentProcessorOptions options) { - if (builder == null) { - throw new IllegalArgumentException("builder must not be null"); - } - return MyOSProcessors.configure(ConversationProcessors.configure(builder, options)); - } -} diff --git a/src/main/java/blue/contract/processor/MyOSProcessors.java b/src/main/java/blue/contract/processor/MyOSProcessors.java deleted file mode 100644 index 2896673..0000000 --- a/src/main/java/blue/contract/processor/MyOSProcessors.java +++ /dev/null @@ -1,32 +0,0 @@ -package blue.contract.processor; - -import blue.contract.processor.myos.MyOSTimelineChannelProcessor; -import blue.language.Blue; -import blue.language.processor.DocumentProcessor; -import blue.language.utils.TypeClassResolver; -import blue.repo.v1_3_0.BlueRepositoryV1_3_0; - -public final class MyOSProcessors { - private MyOSProcessors() { - } - - public static Blue registerWith(Blue blue) { - if (blue == null) { - throw new IllegalArgumentException("blue must not be null"); - } - BlueRepositoryV1_3_0.registerAll(blue.getDocumentProcessor().getContractTypeResolver()); - blue.registerContractProcessor(new MyOSTimelineChannelProcessor()); - return blue; - } - - public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder) { - if (builder == null) { - throw new IllegalArgumentException("builder must not be null"); - } - TypeClassResolver resolver = BlueRepositoryV1_3_0.registerAll( - new TypeClassResolver("blue.language.processor.model")); - return builder - .withContractTypeResolver(resolver) - .registerContractProcessor(new MyOSTimelineChannelProcessor()); - } -} diff --git a/src/main/java/blue/contract/processor/conversation/expression/ExpressionEvaluator.java b/src/main/java/blue/contract/processor/conversation/expression/ExpressionEvaluator.java deleted file mode 100644 index 841055e..0000000 --- a/src/main/java/blue/contract/processor/conversation/expression/ExpressionEvaluator.java +++ /dev/null @@ -1,8 +0,0 @@ -package blue.contract.processor.conversation.expression; - -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.language.model.Node; - -public interface ExpressionEvaluator { - Node evaluate(Node value, StepExecutionContext context); -} diff --git a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java deleted file mode 100644 index 1675375..0000000 --- a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionEvaluator.java +++ /dev/null @@ -1,71 +0,0 @@ -package blue.contract.processor.conversation.expression; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.JavaScriptValues; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsStepBindings; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.language.model.Node; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class QuickJsExpressionEvaluator implements ExpressionEvaluator { - private static final Pattern FULL_EXPRESSION = Pattern.compile("^\\$\\{([\\s\\S]*)}$"); - - private final JavaScriptRuntime runtime; - private final long hostGasLimit; - - public QuickJsExpressionEvaluator() { - this(new NodeQuickJsRuntime()); - } - - public QuickJsExpressionEvaluator(JavaScriptRuntime runtime) { - this(runtime, QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT); - } - - public QuickJsExpressionEvaluator(JavaScriptRuntime runtime, long hostGasLimit) { - if (runtime == null) { - throw new IllegalArgumentException("runtime must not be null"); - } - if (hostGasLimit <= 0L) { - throw new IllegalArgumentException("hostGasLimit must be positive"); - } - this.runtime = runtime; - this.hostGasLimit = hostGasLimit; - } - - @Override - public Node evaluate(Node value, StepExecutionContext context) { - if (value == null) { - return null; - } - Object raw = value.getValue(); - if (!(raw instanceof String)) { - return value.clone(); - } - Matcher expression = FULL_EXPRESSION.matcher((String) raw); - if (!expression.matches()) { - return value.clone(); - } - JavaScriptEvaluationRequest request = new JavaScriptEvaluationRequest( - expression.group(1).trim(), - JavaScriptEvaluationRequest.Mode.EXPRESSION, - QuickJsStepBindings.from(context), - hostGasLimit); - try { - JavaScriptEvaluationResult result = runtime.evaluate(request); - context.processorContext().consumeGas(result.hostGasUsed()); - return JavaScriptValues.toNode(result.value()); - } catch (JavaScriptExecutionException ex) { - if (ex.hasGasUsage()) { - context.processorContext().consumeGas(ex.hostGasUsed()); - } - context.processorContext().throwFatal("QuickJS expression evaluation failed: " + ex.getMessage()); - return null; - } - } -} diff --git a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java b/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java deleted file mode 100644 index 0916355..0000000 --- a/src/main/java/blue/contract/processor/conversation/expression/QuickJsExpressionResolver.java +++ /dev/null @@ -1,254 +0,0 @@ -package blue.contract.processor.conversation.expression; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.JavaScriptValues; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import blue.contract.processor.conversation.javascript.QuickJsStepBindings; -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.language.model.Node; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.function.BiPredicate; -import java.util.function.Predicate; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class QuickJsExpressionResolver { - private static final Pattern FULL_EXPRESSION = Pattern.compile("^\\$\\{([\\s\\S]*)}$"); - - private final JavaScriptRuntime runtime; - private final long hostGasLimit; - - public QuickJsExpressionResolver() { - this(new NodeQuickJsRuntime()); - } - - public QuickJsExpressionResolver(JavaScriptRuntime runtime) { - this(runtime, QuickJsGas.DEFAULT_EXPRESSION_HOST_GAS_LIMIT); - } - - public QuickJsExpressionResolver(JavaScriptRuntime runtime, long hostGasLimit) { - if (runtime == null) { - throw new IllegalArgumentException("runtime must not be null"); - } - if (hostGasLimit <= 0L) { - throw new IllegalArgumentException("hostGasLimit must be positive"); - } - this.runtime = runtime; - this.hostGasLimit = hostGasLimit; - } - - public Node resolve(Node value, StepExecutionContext context) { - return resolve(value, context, all(), all()); - } - - public Node resolve(Node value, - StepExecutionContext context, - Predicate include, - Predicate shouldDescend) { - return resolve(value, context, include, adapt(shouldDescend)); - } - - public Node resolve(Node value, - StepExecutionContext context, - Predicate include, - BiPredicate shouldDescend) { - EvaluationCounter counter = new EvaluationCounter(); - try { - Node resolved = resolve(value, - QuickJsStepBindings.from(context), - include, - shouldDescend, - "/", - counter); - if (counter.hostGasUsed > 0L) { - context.processorContext().consumeGas(counter.hostGasUsed); - } - return resolved; - } catch (JavaScriptExecutionException ex) { - long hostGasUsed = counter.hostGasUsed; - if (ex.hasGasUsage()) { - hostGasUsed += ex.hostGasUsed(); - } - if (hostGasUsed > 0L) { - context.processorContext().consumeGas(hostGasUsed); - } - context.processorContext().throwFatal(ex.getMessage()); - return null; - } - } - - public Node resolve(Node value, Map bindings) { - return resolve(value, bindings, all(), all()); - } - - public Node resolve(Node value, - Map bindings, - Predicate include, - Predicate shouldDescend) { - return resolve(value, bindings, include, adapt(shouldDescend)); - } - - public Node resolve(Node value, - Map bindings, - Predicate include, - BiPredicate shouldDescend) { - return resolve(value, bindings, include, shouldDescend, "/", new EvaluationCounter()); - } - - private Node resolve(Node value, - Map bindings, - Predicate include, - BiPredicate shouldDescend, - String pointer, - EvaluationCounter counter) { - if (value == null) { - return null; - } - Object raw = value.getValue(); - if (raw instanceof String) { - return test(include, pointer) ? resolveString((String) raw, bindings, counter) : value.clone(); - } - if (!test(shouldDescend, pointer, value)) { - return value.clone(); - } - if (value.getItems() != null) { - List items = new ArrayList(); - for (int i = 0; i < value.getItems().size(); i++) { - items.add(resolve(value.getItems().get(i), - bindings, - include, - shouldDescend, - append(pointer, Integer.toString(i)), - counter)); - } - return copyMetadata(value).items(items); - } - if (value.getProperties() != null) { - Map properties = new LinkedHashMap(); - for (Map.Entry entry : value.getProperties().entrySet()) { - String childPointer = append(pointer, entry.getKey()); - if (test(shouldDescend, childPointer, entry.getValue())) { - properties.put(entry.getKey(), resolve(entry.getValue(), - bindings, - include, - shouldDescend, - childPointer, - counter)); - } else { - properties.put(entry.getKey(), entry.getValue().clone()); - } - } - return copyMetadata(value).properties(properties); - } - return value.clone(); - } - - private Node resolveString(String value, Map bindings, EvaluationCounter counter) { - Matcher full = FULL_EXPRESSION.matcher(value); - if (full.matches()) { - JavaScriptEvaluationResult result = evaluate(full.group(1).trim(), bindings); - counter.hostGasUsed += result.hostGasUsed(); - return JavaScriptValues.toNode(result.value()); - } - if (!value.contains("${")) { - return new Node().value(value); - } - StringBuilder resolved = new StringBuilder(); - int position = 0; - while (position < value.length()) { - int start = value.indexOf("${", position); - if (start < 0) { - resolved.append(value.substring(position)); - break; - } - resolved.append(value.substring(position, start)); - int end = value.indexOf('}', start + 2); - if (end < 0) { - resolved.append(value.substring(start)); - break; - } - String expression = value.substring(start + 2, end).trim(); - JavaScriptEvaluationResult result = evaluate(expression, bindings); - counter.hostGasUsed += result.hostGasUsed(); - resolved.append(result.value() != null ? String.valueOf(result.value()) : "null"); - position = end + 1; - } - return new Node().value(resolved.toString()); - } - - private JavaScriptEvaluationResult evaluate(String expression, Map bindings) { - try { - return runtime.evaluate(new JavaScriptEvaluationRequest(expression, - JavaScriptEvaluationRequest.Mode.EXPRESSION, - bindings, - hostGasLimit)); - } catch (JavaScriptExecutionException ex) { - throw new JavaScriptExecutionException("QuickJS expression resolution failed: " + ex.getMessage(), - ex, - ex.hasGasUsage() ? ex.wasmGasUsed() : null, - ex.hasGasUsage() ? ex.hostGasUsed() : null); - } - } - - private Node copyMetadata(Node source) { - return new Node() - .name(source.getName()) - .description(source.getDescription()) - .type(source.getType() != null ? source.getType().clone() : null) - .itemType(source.getItemType() != null ? source.getItemType().clone() : null) - .keyType(source.getKeyType() != null ? source.getKeyType().clone() : null) - .valueType(source.getValueType() != null ? source.getValueType().clone() : null) - .blueId(source.getBlueId()) - .schema(source.getSchema()) - .mergePolicy(source.getMergePolicy()) - .previousBlueId(source.getPreviousBlueId()) - .position(source.getPosition()) - .blue(source.getBlue() != null ? source.getBlue().clone() : null) - .inlineValue(source.isInlineValue()); - } - - private boolean test(Predicate predicate, String pointer) { - return predicate == null || predicate.test(pointer); - } - - private boolean test(BiPredicate predicate, String pointer, Node node) { - return predicate == null || predicate.test(pointer, node); - } - - private static Predicate all() { - return new Predicate() { - @Override - public boolean test(String value) { - return true; - } - }; - } - - private static BiPredicate adapt(final Predicate predicate) { - return new BiPredicate() { - @Override - public boolean test(String pointer, Node node) { - return predicate == null || predicate.test(pointer); - } - }; - } - - private String append(String parent, String segment) { - String escaped = segment.replace("~", "~0").replace("/", "~1"); - if (parent == null || "/".equals(parent)) { - return "/" + escaped; - } - return parent + "/" + escaped; - } - - private static final class EvaluationCounter { - private long hostGasUsed; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/expression/SimpleExpressionEvaluator.java b/src/main/java/blue/contract/processor/conversation/expression/SimpleExpressionEvaluator.java deleted file mode 100644 index 761dd24..0000000 --- a/src/main/java/blue/contract/processor/conversation/expression/SimpleExpressionEvaluator.java +++ /dev/null @@ -1,112 +0,0 @@ -package blue.contract.processor.conversation.expression; - -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.language.model.Node; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class SimpleExpressionEvaluator implements ExpressionEvaluator { - private static final Pattern FULL_EXPRESSION = Pattern.compile("^\\$\\{(.+)}$"); - private static final Pattern BINARY_EXPRESSION = Pattern.compile("^(.+)\\s+([+-])\\s+(.+)$"); - private static final Pattern DOCUMENT_REFERENCE = Pattern.compile("^document\\('([^']*)'\\)$"); - - @Override - public Node evaluate(Node value, StepExecutionContext context) { - if (value == null) { - return null; - } - Object raw = value.getValue(); - if (!(raw instanceof String)) { - return value.clone(); - } - Matcher fullExpression = FULL_EXPRESSION.matcher((String) raw); - if (!fullExpression.matches()) { - return value.clone(); - } - BigInteger result = evaluateIntegralExpression(fullExpression.group(1).trim(), context); - return new Node().value(result); - } - - private BigInteger evaluateIntegralExpression(String expression, StepExecutionContext context) { - Matcher binary = BINARY_EXPRESSION.matcher(expression); - if (!binary.matches()) { - context.processorContext().throwFatal("Unsupported expression: " + expression); - return null; - } - BigInteger left = readIntegralOperand(binary.group(1).trim(), context); - BigInteger right = readIntegralOperand(binary.group(3).trim(), context); - String operator = binary.group(2); - if ("+".equals(operator)) { - return left.add(right); - } - if ("-".equals(operator)) { - return left.subtract(right); - } - context.processorContext().throwFatal("Unsupported expression operator: " + operator); - return null; - } - - private BigInteger readIntegralOperand(String operand, StepExecutionContext context) { - Node node; - if (operand.startsWith("event.")) { - node = readEventPath(context.event(), operand.substring("event.".length()), context); - } else { - Matcher documentReference = DOCUMENT_REFERENCE.matcher(operand); - if (!documentReference.matches()) { - context.processorContext().throwFatal("Unsupported expression operand: " + operand); - return null; - } - String pointer = documentReference.group(1); - node = context.processorContext().documentAt(context.processorContext().resolvePointer(pointer)); - } - return integralValue(node, operand, context); - } - - private Node readEventPath(Node event, String path, StepExecutionContext context) { - if (event == null || path == null || path.isEmpty()) { - context.processorContext().throwFatal("Unsupported event expression path: event." + path); - return null; - } - Node current = event; - String[] parts = path.split("\\."); - for (String part : parts) { - if (current == null || current.getProperties() == null || !current.getProperties().containsKey(part)) { - context.processorContext().throwFatal("Event expression path not found: event." + path); - return null; - } - current = current.getProperties().get(part); - } - return current; - } - - private BigInteger integralValue(Node node, String operand, StepExecutionContext context) { - if (node == null) { - context.processorContext().throwFatal("Expression operand resolved to nothing: " + operand); - return null; - } - Object value = node.getValue(); - if (value instanceof BigInteger) { - return (BigInteger) value; - } - if (value instanceof BigDecimal) { - BigDecimal decimal = (BigDecimal) value; - try { - return decimal.toBigIntegerExact(); - } catch (ArithmeticException ex) { - context.processorContext().throwFatal("Expression operand is not an integer: " + operand); - return null; - } - } - if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) { - return BigInteger.valueOf(((Number) value).longValue()); - } - if (node.getItems() != null || node.getProperties() != null) { - context.processorContext().throwFatal("Expression operand is not a scalar integer: " + operand); - return null; - } - context.processorContext().throwFatal("Expression operand is not an integer: " + operand); - return null; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationRequest.java b/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationRequest.java deleted file mode 100644 index 946bc7d..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationRequest.java +++ /dev/null @@ -1,53 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -public final class JavaScriptEvaluationRequest { - public enum Mode { - EXPRESSION, - BLOCK - } - - private final String code; - private final Mode mode; - private final Map bindings; - private final long hostGasLimit; - - public JavaScriptEvaluationRequest(String code, - Mode mode, - Map bindings, - long hostGasLimit) { - if (code == null) { - throw new IllegalArgumentException("code must not be null"); - } - if (mode == null) { - throw new IllegalArgumentException("mode must not be null"); - } - if (hostGasLimit < 0) { - throw new IllegalArgumentException("hostGasLimit must not be negative"); - } - this.code = code; - this.mode = mode; - this.bindings = Collections.unmodifiableMap(new LinkedHashMap( - bindings != null ? bindings : Collections.emptyMap())); - this.hostGasLimit = hostGasLimit; - } - - public String code() { - return code; - } - - public Mode mode() { - return mode; - } - - public Map bindings() { - return bindings; - } - - public long hostGasLimit() { - return hostGasLimit; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationResult.java b/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationResult.java deleted file mode 100644 index 66058a4..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptEvaluationResult.java +++ /dev/null @@ -1,31 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -public final class JavaScriptEvaluationResult { - private final Object value; - private final long wasmGasUsed; - private final long hostGasUsed; - - public JavaScriptEvaluationResult(Object value, long wasmGasUsed, long hostGasUsed) { - if (wasmGasUsed < 0) { - throw new IllegalArgumentException("wasmGasUsed must not be negative"); - } - if (hostGasUsed < 0) { - throw new IllegalArgumentException("hostGasUsed must not be negative"); - } - this.value = value; - this.wasmGasUsed = wasmGasUsed; - this.hostGasUsed = hostGasUsed; - } - - public Object value() { - return value; - } - - public long wasmGasUsed() { - return wasmGasUsed; - } - - public long hostGasUsed() { - return hostGasUsed; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptExecutionException.java b/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptExecutionException.java deleted file mode 100644 index 651bd02..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptExecutionException.java +++ /dev/null @@ -1,36 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -public class JavaScriptExecutionException extends RuntimeException { - private final Long wasmGasUsed; - private final Long hostGasUsed; - - public JavaScriptExecutionException(String message) { - this(message, null, null, null); - } - - public JavaScriptExecutionException(String message, Throwable cause) { - this(message, cause, null, null); - } - - public JavaScriptExecutionException(String message, long wasmGasUsed, long hostGasUsed) { - this(message, null, wasmGasUsed, hostGasUsed); - } - - public JavaScriptExecutionException(String message, Throwable cause, Long wasmGasUsed, Long hostGasUsed) { - super(message, cause); - this.wasmGasUsed = wasmGasUsed; - this.hostGasUsed = hostGasUsed; - } - - public boolean hasGasUsage() { - return wasmGasUsed != null && hostGasUsed != null; - } - - public long wasmGasUsed() { - return wasmGasUsed != null ? wasmGasUsed.longValue() : -1L; - } - - public long hostGasUsed() { - return hostGasUsed != null ? hostGasUsed.longValue() : -1L; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptRuntime.java b/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptRuntime.java deleted file mode 100644 index 5a7b4f9..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptRuntime.java +++ /dev/null @@ -1,5 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -public interface JavaScriptRuntime { - JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request); -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptValues.java b/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptValues.java deleted file mode 100644 index a4f7420..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/JavaScriptValues.java +++ /dev/null @@ -1,99 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -import blue.language.model.Node; -import blue.language.utils.NodeToMapListOrValue; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public final class JavaScriptValues { - private static final ObjectMapper JSON_WITH_NULLS = new ObjectMapper(new JsonFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS); - - private JavaScriptValues() { - } - - public static Node toNode(Object value) { - if (value == null) { - return new Node(); - } - try { - String json = JSON_WITH_NULLS.writeValueAsString(value); - return JSON_WITH_NULLS.readValue(json, Node.class); - } catch (IOException ex) { - throw new JavaScriptExecutionException("Failed to convert JavaScript value to Blue node", ex); - } - } - - public static Object simple(Node node) { - return node == null ? null : NodeToMapListOrValue.get(node, NodeToMapListOrValue.Strategy.SIMPLE); - } - - public static Object official(Node node) { - return node == null ? null : NodeToMapListOrValue.get(node, NodeToMapListOrValue.Strategy.OFFICIAL); - } - - public static Map stepResults(Map results) { - Map normalized = new LinkedHashMap(); - if (results == null) { - return normalized; - } - for (Map.Entry entry : results.entrySet()) { - Object result = entry.getValue(); - if (result instanceof Node) { - normalized.put(entry.getKey(), simple((Node) result)); - } else { - normalized.put(entry.getKey(), result); - } - } - return normalized; - } - - public static Map metadataIndex(Node node) { - Map index = new LinkedHashMap(); - indexMetadata(index, "/", node); - return index; - } - - private static void indexMetadata(Map index, String pointer, Node node) { - if (node == null) { - return; - } - if (node.getName() != null) { - index.put(append(pointer, "name"), node.getName()); - } - if (node.getDescription() != null) { - index.put(append(pointer, "description"), node.getDescription()); - } - if (node.getValue() != null) { - index.put(append(pointer, "value"), node.getValue()); - } - if (node.getType() != null) { - indexMetadata(index, append(pointer, "type"), node.getType()); - } - if (node.getProperties() != null) { - for (Map.Entry entry : node.getProperties().entrySet()) { - indexMetadata(index, append(pointer, entry.getKey()), entry.getValue()); - } - } - List items = node.getItems(); - if (items != null) { - for (int i = 0; i < items.size(); i++) { - indexMetadata(index, append(pointer, Integer.toString(i)), items.get(i)); - } - } - } - - private static String append(String parent, String segment) { - String escaped = segment.replace("~", "~0").replace("/", "~1"); - if (parent == null || "/".equals(parent)) { - return "/" + escaped; - } - return parent + "/" + escaped; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntime.java b/src/main/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntime.java deleted file mode 100644 index 9b39478..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntime.java +++ /dev/null @@ -1,330 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -import blue.language.utils.UncheckedObjectMapper; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; - -public final class NodeQuickJsRuntime implements JavaScriptRuntime, AutoCloseable { - private static final String QUICKJS_ROOT_PROPERTY = "blue.quickjs.root"; - private static final String BRIDGE_RESOURCE = "/blue/contract/processor/quickjs/evaluate.mjs"; - private static final long DEFAULT_TIMEOUT_MILLIS = 10000L; - private static final ObjectMapper REQUEST_MAPPER = new ObjectMapper(new JsonFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS); - - private final Path blueQuickJsRoot; - private final Path bridgeScript; - private final long timeoutMillis; - private BridgeProcess bridge; - - public NodeQuickJsRuntime() { - this(defaultBlueQuickJsRoot(), resourcePath(BRIDGE_RESOURCE), DEFAULT_TIMEOUT_MILLIS); - } - - public NodeQuickJsRuntime(Path blueQuickJsRoot) { - this(blueQuickJsRoot, resourcePath(BRIDGE_RESOURCE), DEFAULT_TIMEOUT_MILLIS); - } - - public NodeQuickJsRuntime(Path blueQuickJsRoot, Path bridgeScript, long timeoutMillis) { - if (blueQuickJsRoot == null) { - throw new IllegalArgumentException("blueQuickJsRoot must not be null"); - } - if (bridgeScript == null) { - throw new IllegalArgumentException("bridgeScript must not be null"); - } - if (timeoutMillis <= 0L) { - throw new IllegalArgumentException("timeoutMillis must be positive"); - } - this.blueQuickJsRoot = blueQuickJsRoot.toAbsolutePath().normalize(); - this.bridgeScript = bridgeScript.toAbsolutePath().normalize(); - this.timeoutMillis = timeoutMillis; - } - - @Override - public synchronized JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { - Map payload = new LinkedHashMap(); - payload.put("code", request.code()); - payload.put("mode", request.mode().name().toLowerCase(Locale.ROOT)); - payload.put("hostGasLimit", request.hostGasLimit()); - payload.put("wasmGasLimit", Long.toString(QuickJsGas.toWasmFuel(request.hostGasLimit()))); - payload.put("bindings", request.bindings()); - - String input = writeRequest(payload); - BridgeProcess active = bridge(); - try { - active.writeRequest(input); - String stdout = active.readResponse(timeoutMillis); - if (stdout == null) { - active.destroy(); - bridge = null; - throw new JavaScriptExecutionException("QuickJS evaluation timed out after " + timeoutMillis + " ms"); - } - return parseResult(stdout); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - active.destroy(); - bridge = null; - throw new JavaScriptExecutionException("QuickJS evaluation interrupted", ex); - } catch (IOException ex) { - active.destroy(); - bridge = null; - throw new JavaScriptExecutionException("QuickJS bridge failed: " + firstNonEmpty(active.stderr(), ex.getMessage()), ex); - } - } - - @Override - public synchronized void close() { - if (bridge != null) { - bridge.destroy(); - bridge = null; - } - } - - private BridgeProcess bridge() { - if (bridge != null && bridge.isAlive()) { - return bridge; - } - if (bridge != null) { - bridge.destroy(); - } - assertReady(); - bridge = startProcess(); - return bridge; - } - - private BridgeProcess startProcess() { - ProcessBuilder builder = new ProcessBuilder("node", - bridgeScript.toString(), - blueQuickJsRoot.toString()); - builder.directory(new File(blueQuickJsRoot.toString())); - try { - return new BridgeProcess(builder.start()); - } catch (IOException ex) { - throw new JavaScriptExecutionException("Failed to start node for QuickJS evaluation", ex); - } - } - - private String writeRequest(Map payload) { - try { - return REQUEST_MAPPER.writeValueAsString(payload); - } catch (IOException ex) { - throw new JavaScriptExecutionException("Failed to serialize QuickJS request", ex); - } - } - - JavaScriptEvaluationResult parseResult(String stdout) { - Map result = UncheckedObjectMapper.JSON_MAPPER.readValue(stdout, - new TypeReference>() { - }); - Object ok = result.get("ok"); - if (!Boolean.TRUE.equals(ok)) { - Object message = result.get("message"); - Object type = result.get("type"); - String prefix = type != null ? type + ": " : ""; - String errorMessage = prefix + (message != null ? message : "unknown error"); - if (!result.containsKey("wasmGasUsed")) { - throw new JavaScriptExecutionException(errorMessage); - } - long errorWasmGasUsed = parseLong(result.get("wasmGasUsed"), "wasmGasUsed"); - throw new JavaScriptExecutionException(errorMessage, - errorWasmGasUsed, - QuickJsGas.toHostGasUsed(errorWasmGasUsed)); - } - long wasmGasUsed = parseLong(result.get("wasmGasUsed"), "wasmGasUsed"); - long hostGasUsed = QuickJsGas.toHostGasUsed(wasmGasUsed); - return new JavaScriptEvaluationResult(result.get("value"), wasmGasUsed, hostGasUsed); - } - - private long parseLong(Object value, String field) { - if (value instanceof Number) { - return ((Number) value).longValue(); - } - if (value instanceof String) { - try { - return Long.parseLong((String) value); - } catch (NumberFormatException ex) { - throw new JavaScriptExecutionException("QuickJS bridge returned invalid " + field + ": " + value, ex); - } - } - throw new JavaScriptExecutionException("QuickJS bridge returned invalid " + field + ": " + value); - } - - private void assertReady() { - if (!Files.isDirectory(blueQuickJsRoot)) { - throw new JavaScriptExecutionException("blue-quickjs root not found: " + blueQuickJsRoot - + " (set -Dblue.quickjs.root=/path/to/blue-quickjs)"); - } - Path runtimeDist = blueQuickJsRoot.resolve("libs/quickjs-runtime/dist/index.js"); - if (!Files.isRegularFile(runtimeDist)) { - throw new JavaScriptExecutionException("blue-quickjs runtime dist is missing at " + runtimeDist - + "; build it with: cd " + blueQuickJsRoot + " && pnpm nx build quickjs-runtime"); - } - Path manifestDist = blueQuickJsRoot.resolve("libs/abi-manifest/dist/index.js"); - if (!Files.isRegularFile(manifestDist)) { - throw new JavaScriptExecutionException("blue-quickjs ABI manifest dist is missing at " + manifestDist - + "; build it with: cd " + blueQuickJsRoot + " && pnpm nx build quickjs-runtime"); - } - if (!Files.isRegularFile(bridgeScript)) { - throw new JavaScriptExecutionException("QuickJS bridge script not found: " + bridgeScript); - } - } - - private static Path defaultBlueQuickJsRoot() { - String configured = System.getProperty(QUICKJS_ROOT_PROPERTY); - if (configured != null && !configured.trim().isEmpty()) { - return Paths.get(configured); - } - return Paths.get(System.getProperty("user.dir")).toAbsolutePath().getParent().resolve("blue-quickjs"); - } - - private static Path resourcePath(String resourceName) { - URL resource = NodeQuickJsRuntime.class.getResource(resourceName); - if (resource == null) { - throw new JavaScriptExecutionException("Missing QuickJS bridge resource: " + resourceName); - } - if ("file".equals(resource.getProtocol())) { - try { - return Paths.get(resource.toURI()); - } catch (URISyntaxException ex) { - throw new JavaScriptExecutionException("Invalid QuickJS bridge resource URI", ex); - } - } - try { - Path temp = Files.createTempFile("blue-quickjs-evaluate-", ".mjs"); - temp.toFile().deleteOnExit(); - try (InputStream input = NodeQuickJsRuntime.class.getResourceAsStream(resourceName); - OutputStream output = Files.newOutputStream(temp)) { - if (input == null) { - throw new JavaScriptExecutionException("Missing QuickJS bridge resource: " + resourceName); - } - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) >= 0) { - output.write(buffer, 0, read); - } - } - return temp; - } catch (IOException ex) { - throw new JavaScriptExecutionException("Failed to extract QuickJS bridge resource", ex); - } - } - - private static String firstNonEmpty(String first, String second) { - if (first != null && !first.trim().isEmpty()) { - return first.trim(); - } - if (second != null && !second.trim().isEmpty()) { - return second.trim(); - } - return "no output"; - } - - private static final class BridgeProcess { - private final Process process; - private final BufferedWriter stdin; - private final BlockingQueue stdoutLines = new LinkedBlockingQueue(); - private final StringBuilder stderr = new StringBuilder(); - - private BridgeProcess(Process process) { - this.process = process; - this.stdin = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); - startReader("blue-quickjs-stdout", process.getInputStream(), stdoutLines, null); - startReader("blue-quickjs-stderr", process.getErrorStream(), null, stderr); - Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { - @Override - public void run() { - destroy(); - } - }, "blue-quickjs-shutdown")); - } - - private boolean isAlive() { - return process.isAlive(); - } - - private synchronized void writeRequest(String input) throws IOException { - if (!process.isAlive()) { - throw new IOException("QuickJS bridge exited: " + stderr()); - } - stdin.write(input); - stdin.newLine(); - stdin.flush(); - } - - private String readResponse(long timeoutMillis) throws InterruptedException { - return stdoutLines.poll(timeoutMillis, TimeUnit.MILLISECONDS); - } - - private synchronized void destroy() { - try { - stdin.close(); - } catch (IOException ignored) { - // Best-effort cleanup. - } - process.destroy(); - if (process.isAlive()) { - process.destroyForcibly(); - } - } - - private String stderr() { - synchronized (stderr) { - return stderr.toString(); - } - } - - private static void startReader(final String name, - final InputStream input, - final BlockingQueue lines, - final StringBuilder capture) { - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - try { - BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); - String line; - while ((line = reader.readLine()) != null) { - if (lines != null) { - lines.offer(line); - } - if (capture != null) { - synchronized (capture) { - if (capture.length() > 8192) { - capture.delete(0, capture.length() - 8192); - } - capture.append(line).append('\n'); - } - } - } - } catch (IOException ignored) { - // The owning runtime observes process failure on write/read. - } - } - }, name); - thread.setDaemon(true); - thread.start(); - } - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/QuickJsGas.java b/src/main/java/blue/contract/processor/conversation/javascript/QuickJsGas.java deleted file mode 100644 index 8bcf418..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/QuickJsGas.java +++ /dev/null @@ -1,27 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -public final class QuickJsGas { - public static final long WASM_FUEL_PER_HOST_GAS_UNIT = 1700L; - public static final long DEFAULT_EXPRESSION_HOST_GAS_LIMIT = 40000L; - public static final long DEFAULT_CODE_HOST_GAS_LIMIT = 100000L; - - private QuickJsGas() { - } - - public static long toWasmFuel(long hostGas) { - if (hostGas < 0) { - throw new IllegalArgumentException("hostGas must not be negative"); - } - return Math.multiplyExact(hostGas, WASM_FUEL_PER_HOST_GAS_UNIT); - } - - public static long toHostGasUsed(long wasmFuelUsed) { - if (wasmFuelUsed < 0) { - throw new IllegalArgumentException("wasmFuelUsed must not be negative"); - } - if (wasmFuelUsed == 0L) { - return 0L; - } - return ((wasmFuelUsed - 1L) / WASM_FUEL_PER_HOST_GAS_UNIT) + 1L; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/javascript/QuickJsStepBindings.java b/src/main/java/blue/contract/processor/conversation/javascript/QuickJsStepBindings.java deleted file mode 100644 index 682d86e..0000000 --- a/src/main/java/blue/contract/processor/conversation/javascript/QuickJsStepBindings.java +++ /dev/null @@ -1,64 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.language.model.Node; -import java.util.LinkedHashMap; -import java.util.Map; - -public final class QuickJsStepBindings { - private QuickJsStepBindings() { - } - - public static Map from(StepExecutionContext context) { - Map bindings = new LinkedHashMap(); - Node event = context.event(); - Node document = context.processorContext().documentAt(context.processorContext().resolvePointer("/")); - Node contractNode = context.currentContractNode(); - - bindings.put("event", JavaScriptValues.simple(event)); - bindings.put("eventCanonical", JavaScriptValues.official(event)); - bindings.put("document", JavaScriptValues.official(document)); - bindings.put("documentCanonical", JavaScriptValues.official(document)); - bindings.put("documentMetadata", JavaScriptValues.metadataIndex(document)); - bindings.put("steps", JavaScriptValues.stepResults(context.stepResults())); - bindings.put("currentContract", currentContract(context, contractNode)); - bindings.put("currentContractCanonical", currentContractCanonical(contractNode)); - return bindings; - } - - @SuppressWarnings("unchecked") - private static Object currentContract(StepExecutionContext context, Node contractNode) { - Object simple = JavaScriptValues.simple(contractNode); - if (!(simple instanceof Map)) { - return simple; - } - Map copy = new LinkedHashMap((Map) simple); - String channel = context.workflow().getChannelKey(); - if (channel != null && !channel.trim().isEmpty() && !copy.containsKey("channel")) { - copy.put("channel", channel); - } - return copy; - } - - @SuppressWarnings("unchecked") - private static Object currentContractCanonical(Node contractNode) { - Object canonical = JavaScriptValues.official(contractNode); - if (!(canonical instanceof Map)) { - return canonical; - } - Map copy = new LinkedHashMap((Map) canonical); - wrapMetadataValue(copy, "name"); - wrapMetadataValue(copy, "description"); - return copy; - } - - private static void wrapMetadataValue(Map map, String key) { - Object value = map.get(key); - if (!(value instanceof String)) { - return; - } - Map wrapped = new LinkedHashMap(); - wrapped.put("value", value); - map.put(key, wrapped); - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java b/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java deleted file mode 100644 index ce3d195..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/JavaScriptCodeStepExecutor.java +++ /dev/null @@ -1,95 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.JavaScriptExecutionException; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.JavaScriptValues; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import blue.contract.processor.conversation.javascript.QuickJsStepBindings; -import blue.repo.v1_3_0.conversation.JavaScriptCode; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import java.util.List; -import java.util.Map; - -public final class JavaScriptCodeStepExecutor implements WorkflowStepExecutor { - private final JavaScriptRuntime runtime; - private final long hostGasLimit; - - public JavaScriptCodeStepExecutor() { - this(new NodeQuickJsRuntime()); - } - - public JavaScriptCodeStepExecutor(JavaScriptRuntime runtime) { - this(runtime, QuickJsGas.DEFAULT_CODE_HOST_GAS_LIMIT); - } - - public JavaScriptCodeStepExecutor(JavaScriptRuntime runtime, long hostGasLimit) { - if (runtime == null) { - throw new IllegalArgumentException("runtime must not be null"); - } - if (hostGasLimit <= 0L) { - throw new IllegalArgumentException("hostGasLimit must be positive"); - } - this.runtime = runtime; - this.hostGasLimit = hostGasLimit; - } - - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof JavaScriptCode; - } - - @Override - public WorkflowStepResult execute(JavaScriptCode step, StepExecutionContext context) { - if (step == null) { - context.processorContext().throwFatal("JavaScript Code step payload is invalid"); - return WorkflowStepResult.none(); - } - String code = step.getCode(); - if (code == null || code.trim().isEmpty()) { - context.processorContext().throwFatal("JavaScript Code step must include code to execute"); - return WorkflowStepResult.none(); - } - JavaScriptEvaluationRequest request = new JavaScriptEvaluationRequest( - code, - JavaScriptEvaluationRequest.Mode.BLOCK, - QuickJsStepBindings.from(context), - hostGasLimit); - try { - JavaScriptEvaluationResult result = runtime.evaluate(request); - context.processorContext().consumeGas(result.hostGasUsed()); - emitReturnedEvents(result.value(), context); - return WorkflowStepResult.value(result.value()); - } catch (JavaScriptExecutionException ex) { - if (ex.hasGasUsage()) { - context.processorContext().consumeGas(ex.hostGasUsed()); - } - context.processorContext().throwFatal("JavaScript Code execution failed: " + ex.getMessage()); - return WorkflowStepResult.none(); - } - } - - @SuppressWarnings("unchecked") - private void emitReturnedEvents(Object value, StepExecutionContext context) { - if (!(value instanceof Map)) { - return; - } - Map result = (Map) value; - if (!result.containsKey("events")) { - return; - } - Object events = result.get("events"); - if (events == null) { - return; - } - if (!(events instanceof List)) { - context.processorContext().throwFatal("JavaScript Code result events must be a list"); - return; - } - for (Object event : (List) events) { - context.processorContext().emitEvent(JavaScriptValues.toNode(event)); - } - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java b/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java deleted file mode 100644 index 4209466..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/SequentialWorkflowRunner.java +++ /dev/null @@ -1,133 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -import blue.contract.processor.conversation.expression.QuickJsExpressionEvaluator; -import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.language.processor.ProcessorExecutionContext; -import blue.language.model.Node; -import blue.repo.v1_3_0.conversation.JavaScriptCode; -import blue.repo.v1_3_0.conversation.SequentialWorkflow; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.TriggerEvent; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public final class SequentialWorkflowRunner { - private final List> executors; - - public SequentialWorkflowRunner() { - this(defaultExecutors()); - } - - public SequentialWorkflowRunner(List> executors) { - if (executors == null) { - throw new IllegalArgumentException("executors must not be null"); - } - this.executors = Collections.unmodifiableList(new ArrayList>(executors)); - } - - public void execute(SequentialWorkflow workflow, ProcessorExecutionContext context) { - if (workflow.getSteps() == null) { - return; - } - Map stepResults = new LinkedHashMap(); - Node contractNode = context.contractNode(); - List stepNodes = stepNodes(contractNode); - List steps = workflow.getSteps(); - for (int i = 0; i < steps.size(); i++) { - SequentialWorkflowStep step = steps.get(i); - Node stepNode = i < stepNodes.size() ? stepNodes.get(i) : null; - WorkflowStepResult result = executeStep(workflow, step, stepNode, contractNode, i, stepResults, context); - if (result != null && result.hasValue()) { - stepResults.put(stepKey(stepNode, i), result.value()); - } - } - } - - private WorkflowStepResult executeStep(SequentialWorkflow workflow, - SequentialWorkflowStep step, - Node stepNode, - Node contractNode, - int stepIndex, - Map stepResults, - ProcessorExecutionContext context) { - if (step == null) { - context.throwFatal("Unsupported null sequential workflow step"); - return WorkflowStepResult.none(); - } - for (WorkflowStepExecutor executor : executors) { - if (executor.supports(step)) { - StepExecutionContext stepContext = new StepExecutionContext(context, - workflow, - step, - stepNode, - contractNode, - stepIndex, - stepResults); - return executeSupported(executor, step, stepContext); - } - } - context.throwFatal("Unsupported sequential workflow step: " + stepName(step)); - return WorkflowStepResult.none(); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - private WorkflowStepResult executeSupported(WorkflowStepExecutor executor, - SequentialWorkflowStep step, - StepExecutionContext context) { - return executor.execute(step, context); - } - - private String stepName(SequentialWorkflowStep step) { - if (step instanceof TriggerEvent) { - return "Conversation/Trigger Event"; - } - if (step instanceof JavaScriptCode) { - return "Conversation/JavaScript Code"; - } - return step.getClass().getName(); - } - - private static List> defaultExecutors() { - JavaScriptRuntime runtime = new NodeQuickJsRuntime(); - return executorsFor(runtime); - } - - public static SequentialWorkflowRunner withJavaScriptRuntime(JavaScriptRuntime runtime) { - if (runtime == null) { - throw new IllegalArgumentException("runtime must not be null"); - } - return new SequentialWorkflowRunner(executorsFor(runtime)); - } - - private static List> executorsFor(JavaScriptRuntime runtime) { - QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(runtime); - return Arrays.>asList( - new TriggerEventStepExecutor(resolver), - new JavaScriptCodeStepExecutor(runtime), - new UpdateDocumentStepExecutor(new QuickJsExpressionEvaluator(runtime))); - } - - private List stepNodes(Node contractNode) { - if (contractNode == null || contractNode.getProperties() == null) { - return Collections.emptyList(); - } - Node steps = contractNode.getProperties().get("steps"); - if (steps == null || steps.getItems() == null) { - return Collections.emptyList(); - } - return steps.getItems(); - } - - private String stepKey(Node stepNode, int index) { - if (stepNode != null && stepNode.getName() != null && !stepNode.getName().trim().isEmpty()) { - return stepNode.getName().trim(); - } - return "Step" + (index + 1); - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/StepExecutionContext.java b/src/main/java/blue/contract/processor/conversation/workflow/StepExecutionContext.java deleted file mode 100644 index b28c121..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/StepExecutionContext.java +++ /dev/null @@ -1,77 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -import blue.language.model.Node; -import blue.language.processor.ProcessorExecutionContext; -import blue.repo.v1_3_0.conversation.SequentialWorkflow; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -public final class StepExecutionContext { - private final ProcessorExecutionContext processorContext; - private final SequentialWorkflow workflow; - private final SequentialWorkflowStep step; - private final Node stepNode; - private final Node currentContractNode; - private final int stepIndex; - private final Map stepResults; - private final Node event; - - public StepExecutionContext(ProcessorExecutionContext processorContext, - SequentialWorkflow workflow, - SequentialWorkflowStep step, - Node stepNode, - Node currentContractNode, - int stepIndex, - Map stepResults) { - if (processorContext == null) { - throw new IllegalArgumentException("processorContext must not be null"); - } - if (workflow == null) { - throw new IllegalArgumentException("workflow must not be null"); - } - this.processorContext = processorContext; - this.workflow = workflow; - this.step = step; - this.stepNode = stepNode != null ? stepNode.clone() : null; - this.currentContractNode = currentContractNode != null ? currentContractNode.clone() : null; - this.stepIndex = stepIndex; - this.stepResults = Collections.unmodifiableMap(new LinkedHashMap( - stepResults != null ? stepResults : Collections.emptyMap())); - Node currentEvent = processorContext.event(); - this.event = currentEvent != null ? currentEvent.clone() : null; - } - - public ProcessorExecutionContext processorContext() { - return processorContext; - } - - public SequentialWorkflow workflow() { - return workflow; - } - - public SequentialWorkflowStep step() { - return step; - } - - public Node stepNode() { - return stepNode != null ? stepNode.clone() : null; - } - - public Node currentContractNode() { - return currentContractNode != null ? currentContractNode.clone() : null; - } - - public int stepIndex() { - return stepIndex; - } - - public Map stepResults() { - return stepResults; - } - - public Node event() { - return event != null ? event.clone() : null; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/TriggerEventStepExecutor.java b/src/main/java/blue/contract/processor/conversation/workflow/TriggerEventStepExecutor.java deleted file mode 100644 index 2698410..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/TriggerEventStepExecutor.java +++ /dev/null @@ -1,116 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; -import blue.language.model.Node; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.TriggerEvent; -import java.util.Map; -import java.util.function.BiPredicate; -import java.util.function.Predicate; - -public final class TriggerEventStepExecutor implements WorkflowStepExecutor { - private final QuickJsExpressionResolver resolver; - - public TriggerEventStepExecutor() { - this(new QuickJsExpressionResolver()); - } - - public TriggerEventStepExecutor(QuickJsExpressionResolver resolver) { - if (resolver == null) { - throw new IllegalArgumentException("resolver must not be null"); - } - this.resolver = resolver; - } - - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof TriggerEvent; - } - - @Override - public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { - if (step == null) { - context.processorContext().throwFatal("Trigger Event step payload is invalid"); - return WorkflowStepResult.none(); - } - if (!hasDeclaredEvent(context.stepNode())) { - context.processorContext().throwFatal("Trigger Event step must declare event payload"); - return WorkflowStepResult.none(); - } - Node event = step.getEvent(); - if (isEmpty(event)) { - context.processorContext().throwFatal("Trigger Event step must declare event payload"); - return WorkflowStepResult.none(); - } - Node resolvedEvent = resolver.resolve(event, - context, - includeAllPointers(), - stopAtEmbeddedDocuments()); - if (resolvedEvent == null) { - return WorkflowStepResult.none(); - } - context.processorContext().emitEvent(resolvedEvent.clone()); - return WorkflowStepResult.none(); - } - - private static Predicate includeAllPointers() { - return new Predicate() { - @Override - public boolean test(String pointer) { - return true; - } - }; - } - - private static BiPredicate stopAtEmbeddedDocuments() { - return new BiPredicate() { - @Override - public boolean test(String pointer, Node node) { - return "/".equals(pointer) || !hasContracts(node); - } - }; - } - - private static boolean hasContracts(Node node) { - return node != null - && node.getProperties() != null - && node.getProperties().containsKey("contracts"); - } - - private static boolean hasDeclaredEvent(Node stepNode) { - if (stepNode == null) { - return true; - } - if (stepNode.getProperties() == null || !stepNode.getProperties().containsKey("event")) { - return false; - } - return !isEmpty(stepNode.getProperties().get("event")); - } - - private static boolean isEmpty(Node node) { - if (node == null) { - return true; - } - return node.getType() == null - && node.getItemType() == null - && node.getKeyType() == null - && node.getValueType() == null - && node.getValue() == null - && empty(node.getItems()) - && empty(node.getProperties()) - && node.getBlueId() == null - && node.getSchema() == null - && node.getMergePolicy() == null - && node.getPreviousBlueId() == null - && node.getPosition() == null - && node.getBlue() == null; - } - - private static boolean empty(Map map) { - return map == null || map.isEmpty(); - } - - private static boolean empty(Iterable items) { - return items == null || !items.iterator().hasNext(); - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/UpdateDocumentStepExecutor.java b/src/main/java/blue/contract/processor/conversation/workflow/UpdateDocumentStepExecutor.java deleted file mode 100644 index 639eb3d..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/UpdateDocumentStepExecutor.java +++ /dev/null @@ -1,70 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -import blue.contract.processor.conversation.expression.ExpressionEvaluator; -import blue.language.model.Node; -import blue.language.processor.model.JsonPatch; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.UpdateDocument; -import blue.repo.v1_3_0.core.JsonPatchEntry; - -public final class UpdateDocumentStepExecutor implements WorkflowStepExecutor { - private final ExpressionEvaluator expressionEvaluator; - - public UpdateDocumentStepExecutor(ExpressionEvaluator expressionEvaluator) { - if (expressionEvaluator == null) { - throw new IllegalArgumentException("expressionEvaluator must not be null"); - } - this.expressionEvaluator = expressionEvaluator; - } - - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof UpdateDocument; - } - - @Override - public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { - if (step.getChangeset() == null) { - return WorkflowStepResult.none(); - } - for (JsonPatchEntry entry : step.getChangeset()) { - context.processorContext().applyPatch(toPatch(entry, context)); - } - return WorkflowStepResult.none(); - } - - private JsonPatch toPatch(JsonPatchEntry entry, StepExecutionContext context) { - if (entry == null) { - context.processorContext().throwFatal("Update Document changeset contains a null patch entry"); - return null; - } - String op = entry.getOp(); - String path = entry.getPath(); - if (op == null || op.trim().isEmpty()) { - context.processorContext().throwFatal("Update Document patch operation is required"); - return null; - } - if (path == null || path.trim().isEmpty()) { - context.processorContext().throwFatal("Update Document patch path is required"); - return null; - } - String absolutePath = context.processorContext().resolvePointer(path); - String normalizedOp = op.trim().toLowerCase(); - if ("remove".equals(normalizedOp)) { - return JsonPatch.remove(absolutePath); - } - Node value = expressionEvaluator.evaluate(entry.getVal(), context); - if (value == null) { - context.processorContext().throwFatal("Update Document patch value is required for operation: " + op); - return null; - } - if ("add".equals(normalizedOp)) { - return JsonPatch.add(absolutePath, value); - } - if ("replace".equals(normalizedOp)) { - return JsonPatch.replace(absolutePath, value); - } - context.processorContext().throwFatal("Unsupported Update Document patch operation: " + op); - return null; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepResult.java b/src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepResult.java deleted file mode 100644 index bbda413..0000000 --- a/src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepResult.java +++ /dev/null @@ -1,29 +0,0 @@ -package blue.contract.processor.conversation.workflow; - -public final class WorkflowStepResult { - private static final WorkflowStepResult NONE = new WorkflowStepResult(false, null); - - private final boolean hasValue; - private final Object value; - - private WorkflowStepResult(boolean hasValue, Object value) { - this.hasValue = hasValue; - this.value = value; - } - - public static WorkflowStepResult none() { - return NONE; - } - - public static WorkflowStepResult value(Object value) { - return new WorkflowStepResult(true, value); - } - - public boolean hasValue() { - return hasValue; - } - - public Object value() { - return value; - } -} diff --git a/src/main/java/blue/contract/processor/myos/MyOSTimelineChannelProcessor.java b/src/main/java/blue/contract/processor/myos/MyOSTimelineChannelProcessor.java deleted file mode 100644 index daea138..0000000 --- a/src/main/java/blue/contract/processor/myos/MyOSTimelineChannelProcessor.java +++ /dev/null @@ -1,62 +0,0 @@ -package blue.contract.processor.myos; - -import blue.contract.processor.conversation.TimelineProviderSupport; -import blue.language.model.Node; -import blue.language.processor.ChannelCheckpointContext; -import blue.language.processor.ChannelEvaluation; -import blue.language.processor.ChannelEvaluationContext; -import blue.language.processor.ChannelProcessor; -import blue.repo.v1_3_0.myos.MyOSTimelineChannel; -import blue.repo.v1_3_0.myos.MyOSTimelineEntry; - -public final class MyOSTimelineChannelProcessor implements ChannelProcessor { - @Override - public Class contractType() { - return MyOSTimelineChannel.class; - } - - @Override - public ChannelEvaluation evaluate(MyOSTimelineChannel contract, ChannelEvaluationContext context) { - Node eventNode = context.event(); - if (!TimelineProviderSupport.hasType(eventNode, MyOSTimelineEntry.blueId(), MyOSTimelineEntry.qualifiedName())) { - return ChannelEvaluation.noMatch(); - } - if (!TimelineProviderSupport.matchesTimelineId(contract, eventNode)) { - return ChannelEvaluation.noMatch(); - } - Node actor = TimelineProviderSupport.property(eventNode, "actor"); - if (!matchesConstraint(contract.getAccountId(), TimelineProviderSupport.textProperty(actor, "accountId"))) { - return ChannelEvaluation.noMatch(); - } - if (!matchesConstraint(contract.getEmail(), TimelineProviderSupport.textProperty(actor, "email"))) { - return ChannelEvaluation.noMatch(); - } - if (!TimelineProviderSupport.matchesEventFilter(contract, eventNode)) { - return ChannelEvaluation.noMatch(); - } - return ChannelEvaluation.match(eventNode); - } - - @Override - public String eventId(MyOSTimelineChannel contract, ChannelEvaluationContext context) { - return TimelineProviderSupport.eventId(context.event()); - } - - @Override - public boolean isNewerEvent(MyOSTimelineChannel contract, ChannelCheckpointContext context) { - return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); - } - - private boolean matchesConstraint(String expected, String actual) { - String trimmed = trimToNull(expected); - return trimmed == null || trimmed.equals(actual); - } - - private String trimToNull(String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } -} diff --git a/src/main/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessor.java b/src/main/java/blue/coordination/processor/CompositeTimelineChannelProcessor.java similarity index 95% rename from src/main/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessor.java rename to src/main/java/blue/coordination/processor/CompositeTimelineChannelProcessor.java index 9f6ddcb..63a9829 100644 --- a/src/main/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessor.java +++ b/src/main/java/blue/coordination/processor/CompositeTimelineChannelProcessor.java @@ -1,4 +1,4 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.model.Node; import blue.language.processor.ChannelCheckpointContext; @@ -9,7 +9,7 @@ import blue.language.processor.model.ChannelEventCheckpoint; import blue.language.processor.model.ChannelContract; import blue.language.processor.model.MarkerContract; -import blue.repo.v1_3_0.conversation.CompositeTimelineChannel; +import blue.repo.coordination.CompositeTimelineChannel; import java.util.ArrayList; import java.util.List; @@ -87,14 +87,13 @@ private Boolean shouldProcessChild(ChannelProcessor processor, ? (ChannelEventCheckpoint) marker : null; Node lastEvent = checkpoint != null ? checkpoint.lastEvent(checkpointKey) : null; - String lastSignature = checkpoint != null ? checkpoint.lastSignature(checkpointKey) : null; ChannelCheckpointContext checkpointContext = ChannelCheckpointContext.of( context.scopePath(), checkpointKey, context.event(), childEvaluation.eventId(), lastEvent, - lastSignature, + null, context.markers()); return processor.isNewerEvent(child, checkpointContext); } diff --git a/src/main/java/blue/coordination/processor/CoordinationBexIntrinsics.java b/src/main/java/blue/coordination/processor/CoordinationBexIntrinsics.java new file mode 100644 index 0000000..e0b4c20 --- /dev/null +++ b/src/main/java/blue/coordination/processor/CoordinationBexIntrinsics.java @@ -0,0 +1,95 @@ +package blue.coordination.processor; + +import blue.bex.api.BexIntrinsicInvocation; +import blue.bex.api.BexIntrinsicProcessor; +import blue.bex.api.BexIntrinsicRegistry; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; +import blue.repo.common.CryptoEd25519Verify; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public final class CoordinationBexIntrinsics { + public static final long COMMON_CRYPTO_ED25519_VERIFY_GAS = 500L; + + private CoordinationBexIntrinsics() { + } + + public static BexIntrinsicRegistry common() { + return registerCommon(BexIntrinsicRegistry.empty()); + } + + public static BexIntrinsicRegistry registerCommon(BexIntrinsicRegistry registry) { + BexIntrinsicRegistry base = registry != null ? registry : BexIntrinsicRegistry.empty(); + return base.with(CryptoEd25519Verify.class, commonCryptoEd25519Verify()); + } + + public static BexIntrinsicProcessor commonCryptoEd25519Verify() { + return invocation -> { + invocation.chargeGas(COMMON_CRYPTO_ED25519_VERIFY_GAS); + return BexValues.scalar(verifyEd25519(invocation)); + }; + } + + private static boolean verifyEd25519(BexIntrinsicInvocation invocation) { + String publicKeyText = textField(invocation.field("publicKey")); + String message = textField(invocation.field("message")); + String signatureText = textField(invocation.field("signature")); + if (publicKeyText == null || message == null || signatureText == null) { + return false; + } + + byte[] publicKey = decodeBase64Url(publicKeyText, 32); + byte[] signature = decodeBase64Url(signatureText, 64); + if (publicKey == null || signature == null) { + return false; + } + + try { + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, new Ed25519PublicKeyParameters(publicKey, 0)); + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + verifier.update(messageBytes, 0, messageBytes.length); + return verifier.verifySignature(signature); + } catch (RuntimeException ex) { + return false; + } + } + + private static String textField(BexValue value) { + if (value == null + || value.isUndefined() + || value.isNull() + || !"text".equals(BexValues.kind(value))) { + return null; + } + return value.asText(); + } + + private static byte[] decodeBase64Url(String value, int expectedLength) { + if (value == null) { + return null; + } + String normalized = value.trim(); + int remainder = normalized.length() % 4; + if (remainder == 1) { + return null; + } + if (remainder != 0) { + StringBuilder builder = new StringBuilder(normalized); + for (int i = remainder; i < 4; i++) { + builder.append('='); + } + normalized = builder.toString(); + } + try { + byte[] decoded = Base64.getUrlDecoder().decode(normalized); + return decoded.length == expectedLength ? decoded : null; + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/blue/contract/processor/conversation/ConversationEventNodes.java b/src/main/java/blue/coordination/processor/CoordinationEventNodes.java similarity index 93% rename from src/main/java/blue/contract/processor/conversation/ConversationEventNodes.java rename to src/main/java/blue/coordination/processor/CoordinationEventNodes.java index 32bb77e..bddc17f 100644 --- a/src/main/java/blue/contract/processor/conversation/ConversationEventNodes.java +++ b/src/main/java/blue/coordination/processor/CoordinationEventNodes.java @@ -1,18 +1,18 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.model.Node; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.OperationRequest; -import blue.repo.v1_3_0.conversation.StatusCompleted; -import blue.repo.v1_3_0.conversation.Timeline; -import blue.repo.v1_3_0.conversation.TimelineEntry; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.OperationRequest; +import blue.repo.coordination.StatusCompleted; +import blue.repo.coordination.Timeline; +import blue.repo.coordination.TimelineEntry; import java.math.BigDecimal; import java.math.BigInteger; import java.util.List; import java.util.Map; -final class ConversationEventNodes { - private ConversationEventNodes() { +final class CoordinationEventNodes { + private CoordinationEventNodes() { } static TimelineEntry timelineEntry(Node node) { @@ -147,13 +147,13 @@ private static String typeIdentity(Node type) { } Object value = type.getValue(); if (value instanceof String) { - String knownBlueId = knownConversationTypeBlueId((String) value); + String knownBlueId = knownCoordinationTypeBlueId((String) value); return knownBlueId != null ? knownBlueId : (String) value; } return null; } - private static String knownConversationTypeBlueId(String qualifiedName) { + private static String knownCoordinationTypeBlueId(String qualifiedName) { if (TimelineEntry.qualifiedName().equals(qualifiedName)) { return TimelineEntry.blueId(); } diff --git a/src/main/java/blue/coordination/processor/CoordinationProcessorOptions.java b/src/main/java/blue/coordination/processor/CoordinationProcessorOptions.java new file mode 100644 index 0000000..6aba1d2 --- /dev/null +++ b/src/main/java/blue/coordination/processor/CoordinationProcessorOptions.java @@ -0,0 +1,73 @@ +package blue.coordination.processor; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; + +public final class CoordinationProcessorOptions { + private final SequentialWorkflowRunner sequentialWorkflowRunner; + private final BexEngine bexEngine; + private final long defaultComputeGasLimit; + private final BexProcessingMetrics processingMetrics; + + private CoordinationProcessorOptions(Builder builder) { + this.sequentialWorkflowRunner = builder.sequentialWorkflowRunner; + this.bexEngine = builder.bexEngine; + this.defaultComputeGasLimit = builder.defaultComputeGasLimit; + this.processingMetrics = builder.processingMetrics; + } + + public SequentialWorkflowRunner sequentialWorkflowRunner() { + return sequentialWorkflowRunner; + } + + public BexEngine bexEngine() { + return bexEngine; + } + + public long defaultComputeGasLimit() { + return defaultComputeGasLimit; + } + + public BexProcessingMetrics processingMetrics() { + return processingMetrics; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private SequentialWorkflowRunner sequentialWorkflowRunner; + private BexEngine bexEngine; + private long defaultComputeGasLimit = 100_000L; + private BexProcessingMetrics processingMetrics; + + public Builder sequentialWorkflowRunner(SequentialWorkflowRunner sequentialWorkflowRunner) { + this.sequentialWorkflowRunner = sequentialWorkflowRunner; + return this; + } + + public Builder bexEngine(BexEngine bexEngine) { + this.bexEngine = bexEngine; + return this; + } + + public Builder defaultComputeGasLimit(long defaultComputeGasLimit) { + if (defaultComputeGasLimit <= 0L) { + throw new IllegalArgumentException("defaultComputeGasLimit must be positive"); + } + this.defaultComputeGasLimit = defaultComputeGasLimit; + return this; + } + + public Builder processingMetrics(BexProcessingMetrics processingMetrics) { + this.processingMetrics = processingMetrics; + return this; + } + + public CoordinationProcessorOptions build() { + return new CoordinationProcessorOptions(this); + } + } +} diff --git a/src/main/java/blue/contract/processor/ConversationProcessors.java b/src/main/java/blue/coordination/processor/CoordinationProcessors.java similarity index 67% rename from src/main/java/blue/contract/processor/ConversationProcessors.java rename to src/main/java/blue/coordination/processor/CoordinationProcessors.java index a1968ae..4a878c2 100644 --- a/src/main/java/blue/contract/processor/ConversationProcessors.java +++ b/src/main/java/blue/coordination/processor/CoordinationProcessors.java @@ -1,29 +1,27 @@ -package blue.contract.processor; +package blue.coordination.processor; -import blue.contract.processor.conversation.CompositeTimelineChannelProcessor; -import blue.contract.processor.conversation.OperationProcessor; -import blue.contract.processor.conversation.SequentialWorkflowOperationProcessor; -import blue.contract.processor.conversation.SequentialWorkflowProcessor; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.bex.api.BexEngine; +import blue.coordination.processor.merge.CoordinationMerging; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; import blue.language.Blue; import blue.language.processor.DocumentProcessor; import blue.language.utils.TypeClassResolver; -import blue.repo.v1_3_0.BlueRepositoryV1_3_0; +import blue.repo.BlueRepositoryModels; -public final class ConversationProcessors { - private ConversationProcessors() { +public final class CoordinationProcessors { + private CoordinationProcessors() { } public static Blue registerWith(Blue blue) { return registerWith(blue, null); } - public static Blue registerWith(Blue blue, BlueDocumentProcessorOptions options) { + public static Blue registerWith(Blue blue, CoordinationProcessorOptions options) { if (blue == null) { throw new IllegalArgumentException("blue must not be null"); } SequentialWorkflowRunner runner = workflowRunner(options); - BlueRepositoryV1_3_0.registerAll(blue.getDocumentProcessor().getContractTypeResolver()); + BlueRepositoryModels.registerAll(blue.getDocumentProcessor().getContractTypeResolver()); blue.registerContractProcessor(new CompositeTimelineChannelProcessor()); blue.registerContractProcessor(new OperationProcessor()); blue.registerContractProcessor(runner != null @@ -32,6 +30,7 @@ public static Blue registerWith(Blue blue, BlueDocumentProcessorOptions options) blue.registerContractProcessor(runner != null ? new SequentialWorkflowOperationProcessor(runner) : new SequentialWorkflowOperationProcessor()); + CoordinationMerging.install(blue); return blue; } @@ -40,12 +39,12 @@ public static DocumentProcessor.Builder configure(DocumentProcessor.Builder buil } public static DocumentProcessor.Builder configure(DocumentProcessor.Builder builder, - BlueDocumentProcessorOptions options) { + CoordinationProcessorOptions options) { if (builder == null) { throw new IllegalArgumentException("builder must not be null"); } SequentialWorkflowRunner runner = workflowRunner(options); - TypeClassResolver resolver = BlueRepositoryV1_3_0.registerAll( + TypeClassResolver resolver = BlueRepositoryModels.registerAll( new TypeClassResolver("blue.language.processor.model")); return builder .withContractTypeResolver(resolver) @@ -59,16 +58,18 @@ public static DocumentProcessor.Builder configure(DocumentProcessor.Builder buil : new SequentialWorkflowOperationProcessor()); } - private static SequentialWorkflowRunner workflowRunner(BlueDocumentProcessorOptions options) { + private static SequentialWorkflowRunner workflowRunner(CoordinationProcessorOptions options) { if (options == null) { return null; } if (options.sequentialWorkflowRunner() != null) { return options.sequentialWorkflowRunner(); } - if (options.javaScriptRuntime() != null) { - return SequentialWorkflowRunner.withJavaScriptRuntime(options.javaScriptRuntime()); - } - return null; + BexEngine bexEngine = options.bexEngine() != null + ? options.bexEngine() + : BexEngine.builder().build(); + return SequentialWorkflowRunner.withBexEngine(bexEngine, + options.defaultComputeGasLimit(), + options.processingMetrics()); } } diff --git a/src/main/java/blue/coordination/processor/CoordinationRepositoryCompatibilityNodeProvider.java b/src/main/java/blue/coordination/processor/CoordinationRepositoryCompatibilityNodeProvider.java new file mode 100644 index 0000000..dbeff07 --- /dev/null +++ b/src/main/java/blue/coordination/processor/CoordinationRepositoryCompatibilityNodeProvider.java @@ -0,0 +1,77 @@ +package blue.coordination.processor; + +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.provider.SequentialNodeProvider; +import blue.repo.coordination.Compute; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class CoordinationRepositoryCompatibilityNodeProvider implements NodeProvider { + private final NodeProvider delegate; + + public CoordinationRepositoryCompatibilityNodeProvider(NodeProvider delegate) { + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + this.delegate = delegate; + } + + public static boolean isInstalled(NodeProvider provider) { + if (provider instanceof CoordinationRepositoryCompatibilityNodeProvider) { + return true; + } + if (provider instanceof SequentialNodeProvider) { + for (NodeProvider child : ((SequentialNodeProvider) provider).getNodeProviders()) { + if (isInstalled(child)) { + return true; + } + } + } + return false; + } + + @Override + public List fetchByBlueId(String blueId) { + List nodes = delegate.fetchByBlueId(blueId); + if (nodes == null || nodes.isEmpty() || !Compute.blueId().equals(baseBlueId(blueId))) { + return nodes; + } + List compatible = new ArrayList(nodes.size()); + for (Node node : nodes) { + compatible.add(sanitizeComputeDefinition(node)); + } + return compatible; + } + + private Node sanitizeComputeDefinition(Node node) { + Node sanitized = node.clone(); + Map properties = sanitized.getProperties(); + if (properties == null || properties.isEmpty()) { + return sanitized; + } + stripRuntimeDefault(properties, "emitEvents"); + stripRuntimeDefault(properties, "returnResult"); + return sanitized; + } + + private void stripRuntimeDefault(Map properties, String key) { + Node field = properties.get(key); + if (field == null || !Boolean.TRUE.equals(field.getValue())) { + return; + } + Node sanitized = field.clone(); + sanitized.value((Object) null); + properties.put(key, sanitized); + } + + private String baseBlueId(String blueId) { + if (blueId == null) { + return null; + } + int fragment = blueId.indexOf('#'); + return fragment >= 0 ? blueId.substring(0, fragment) : blueId; + } +} diff --git a/src/main/java/blue/contract/processor/conversation/OperationProcessor.java b/src/main/java/blue/coordination/processor/OperationProcessor.java similarity index 71% rename from src/main/java/blue/contract/processor/conversation/OperationProcessor.java rename to src/main/java/blue/coordination/processor/OperationProcessor.java index f24376e..52128d5 100644 --- a/src/main/java/blue/contract/processor/conversation/OperationProcessor.java +++ b/src/main/java/blue/coordination/processor/OperationProcessor.java @@ -1,7 +1,7 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.processor.ContractProcessor; -import blue.repo.v1_3_0.conversation.Operation; +import blue.repo.coordination.Operation; public final class OperationProcessor implements ContractProcessor { @Override diff --git a/src/main/java/blue/contract/processor/conversation/OperationRequestMatcher.java b/src/main/java/blue/coordination/processor/OperationRequestMatcher.java similarity index 87% rename from src/main/java/blue/contract/processor/conversation/OperationRequestMatcher.java rename to src/main/java/blue/coordination/processor/OperationRequestMatcher.java index 1ee55c1..6da314b 100644 --- a/src/main/java/blue/contract/processor/conversation/OperationRequestMatcher.java +++ b/src/main/java/blue/coordination/processor/OperationRequestMatcher.java @@ -1,13 +1,13 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.model.Node; import blue.language.processor.HandlerMatchContext; import blue.language.processor.model.InitializationMarker; import blue.language.processor.model.MarkerContract; import blue.language.utils.BlueIdCalculator; -import blue.repo.v1_3_0.conversation.Operation; -import blue.repo.v1_3_0.conversation.OperationRequest; -import blue.repo.v1_3_0.conversation.SequentialWorkflowOperation; +import blue.repo.coordination.Operation; +import blue.repo.coordination.OperationRequest; +import blue.repo.coordination.SequentialWorkflowOperation; import java.util.Map; final class OperationRequestMatcher { @@ -46,12 +46,27 @@ private boolean requestMatches(Node requestPattern, if (requestPattern == null) { return true; } + if (isEmptyRequestPattern(requestPattern)) { + return true; + } if (requestEvent.request() == null) { return false; } return context.matchesEventPattern(requestEvent.patternFor(requestPattern)); } + private boolean isEmptyRequestPattern(Node requestPattern) { + return requestPattern.getType() == null + && requestPattern.getItemType() == null + && requestPattern.getKeyType() == null + && requestPattern.getValueType() == null + && requestPattern.getValue() == null + && requestPattern.getItems() == null + && (requestPattern.getProperties() == null || requestPattern.getProperties().isEmpty()) + && requestPattern.getBlueId() == null + && requestPattern.getSchema() == null; + } + private boolean channelsCompatible(SequentialWorkflowOperation contract, Operation operation) { String operationChannel = trimToNull(operation.getChannel()); if (operationChannel == null) { @@ -116,7 +131,7 @@ static OperationRequestEvent from(Node event) { if (isOperationRequest(event)) { return new OperationRequestEvent(false, event); } - if (!ConversationEventNodes.isTimelineEntry(event)) { + if (!CoordinationEventNodes.isTimelineEntry(event)) { return null; } Node message = property(event, "message"); diff --git a/src/main/java/blue/coordination/processor/RepositoryTypeAliasPreprocessor.java b/src/main/java/blue/coordination/processor/RepositoryTypeAliasPreprocessor.java new file mode 100644 index 0000000..1c5fb45 --- /dev/null +++ b/src/main/java/blue/coordination/processor/RepositoryTypeAliasPreprocessor.java @@ -0,0 +1,89 @@ +package blue.coordination.processor; + +import blue.language.model.Node; +import blue.repo.BlueRepository; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class RepositoryTypeAliasPreprocessor { + private final Map aliases; + + public RepositoryTypeAliasPreprocessor() { + this(BlueRepository.latest()); + } + + public RepositoryTypeAliasPreprocessor(BlueRepository repository) { + this(repository != null ? repository.typeAliases() : null); + } + + public RepositoryTypeAliasPreprocessor(Map aliases) { + this.aliases = aliases != null + ? new LinkedHashMap(aliases) + : new LinkedHashMap(); + } + + public Node preprocess(Node node) { + if (node == null) { + return null; + } + Node copy = node.clone(); + resolve(copy); + return copy; + } + + private void resolve(Node node) { + if (node == null) { + return; + } + String blueId = aliasFor(node.getBlueId()); + if (blueId != null) { + node.blueId(blueId); + } + + node.type(resolveTypeNode(node.getType())); + node.itemType(resolveTypeNode(node.getItemType())); + node.keyType(resolveTypeNode(node.getKeyType())); + node.valueType(resolveTypeNode(node.getValueType())); + + if (node.getItems() != null) { + for (Node item : node.getItems()) { + resolve(item); + } + } + if (node.getProperties() != null) { + for (Node value : node.getProperties().values()) { + resolve(value); + } + } + resolve(node.getContracts()); + resolve(node.getBlue()); + } + + private Node resolveTypeNode(Node typeNode) { + if (typeNode == null) { + return null; + } + String blueId = aliasFor(inlineText(typeNode)); + if (blueId != null) { + return new Node().blueId(blueId); + } + resolve(typeNode); + return typeNode; + } + + private String inlineText(Node node) { + if (node == null || !node.isInlineValue() || node.getValue() == null) { + return null; + } + return String.valueOf(node.getValue()); + } + + private String aliasFor(String value) { + if (value == null) { + return null; + } + return aliases.get(value); + } + +} diff --git a/src/main/java/blue/contract/processor/conversation/SequentialWorkflowOperationProcessor.java b/src/main/java/blue/coordination/processor/SequentialWorkflowOperationProcessor.java similarity index 65% rename from src/main/java/blue/contract/processor/conversation/SequentialWorkflowOperationProcessor.java rename to src/main/java/blue/coordination/processor/SequentialWorkflowOperationProcessor.java index d43734f..1a5cf33 100644 --- a/src/main/java/blue/contract/processor/conversation/SequentialWorkflowOperationProcessor.java +++ b/src/main/java/blue/coordination/processor/SequentialWorkflowOperationProcessor.java @@ -1,12 +1,12 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; import blue.language.processor.HandlerMatchContext; import blue.language.processor.HandlerProcessor; import blue.language.processor.HandlerRegistrationContext; import blue.language.processor.ProcessorExecutionContext; -import blue.repo.v1_3_0.conversation.Operation; -import blue.repo.v1_3_0.conversation.SequentialWorkflowOperation; +import blue.repo.coordination.Operation; +import blue.repo.coordination.SequentialWorkflowOperation; public final class SequentialWorkflowOperationProcessor implements HandlerProcessor { private final SequentialWorkflowRunner runner; @@ -31,7 +31,12 @@ public Class contractType() { @Override public String deriveChannel(SequentialWorkflowOperation contract, HandlerRegistrationContext context) { Operation operation = context.contractAs(contract.getOperation(), Operation.class); - return operation != null ? operation.getChannel() : null; + String channel = operation != null ? trimToNull(operation.getChannel()) : null; + if (channel != null && !context.hasContract(channel)) { + throw new IllegalStateException("Sequential workflow operation '" + context.handlerKey() + + "' references unknown channel '" + channel + "'"); + } + return channel; } @Override @@ -43,4 +48,12 @@ public boolean matches(SequentialWorkflowOperation contract, HandlerMatchContext public void execute(SequentialWorkflowOperation contract, ProcessorExecutionContext context) { runner.execute(contract, context); } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } } diff --git a/src/main/java/blue/contract/processor/conversation/SequentialWorkflowProcessor.java b/src/main/java/blue/coordination/processor/SequentialWorkflowProcessor.java similarity index 73% rename from src/main/java/blue/contract/processor/conversation/SequentialWorkflowProcessor.java rename to src/main/java/blue/coordination/processor/SequentialWorkflowProcessor.java index 79a4c6d..c2e9287 100644 --- a/src/main/java/blue/contract/processor/conversation/SequentialWorkflowProcessor.java +++ b/src/main/java/blue/coordination/processor/SequentialWorkflowProcessor.java @@ -1,10 +1,11 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; import blue.language.processor.HandlerMatchContext; import blue.language.processor.HandlerProcessor; +import blue.language.processor.HandlerRegistrationContext; import blue.language.processor.ProcessorExecutionContext; -import blue.repo.v1_3_0.conversation.SequentialWorkflow; +import blue.repo.coordination.SequentialWorkflow; public final class SequentialWorkflowProcessor implements HandlerProcessor { private final SequentialWorkflowRunner runner; @@ -25,6 +26,11 @@ public Class contractType() { return SequentialWorkflow.class; } + @Override + public String deriveChannel(SequentialWorkflow contract, HandlerRegistrationContext context) { + return contract != null ? contract.getChannel() : null; + } + @Override public boolean matches(SequentialWorkflow contract, HandlerMatchContext context) { return contract.getEvent() == null || context.matchesEventPattern(contract.getEvent()); diff --git a/src/main/java/blue/contract/processor/conversation/TimelineChannelProcessor.java b/src/main/java/blue/coordination/processor/TimelineChannelProcessor.java similarity index 90% rename from src/main/java/blue/contract/processor/conversation/TimelineChannelProcessor.java rename to src/main/java/blue/coordination/processor/TimelineChannelProcessor.java index 04c1dae..0418ecf 100644 --- a/src/main/java/blue/contract/processor/conversation/TimelineChannelProcessor.java +++ b/src/main/java/blue/coordination/processor/TimelineChannelProcessor.java @@ -1,10 +1,10 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.processor.ChannelCheckpointContext; import blue.language.processor.ChannelEvaluation; import blue.language.processor.ChannelEvaluationContext; import blue.language.processor.ChannelProcessor; -import blue.repo.v1_3_0.conversation.TimelineChannel; +import blue.repo.coordination.TimelineChannel; public final class TimelineChannelProcessor implements ChannelProcessor { @Override diff --git a/src/main/java/blue/contract/processor/conversation/TimelineProviderSupport.java b/src/main/java/blue/coordination/processor/TimelineProviderSupport.java similarity index 76% rename from src/main/java/blue/contract/processor/conversation/TimelineProviderSupport.java rename to src/main/java/blue/coordination/processor/TimelineProviderSupport.java index db195ae..329b13d 100644 --- a/src/main/java/blue/contract/processor/conversation/TimelineProviderSupport.java +++ b/src/main/java/blue/coordination/processor/TimelineProviderSupport.java @@ -1,11 +1,11 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.model.Node; import blue.language.processor.ChannelCheckpointContext; import blue.language.processor.ChannelEvaluation; import blue.language.processor.ChannelEvaluationContext; import blue.language.utils.BlueIdCalculator; -import blue.repo.v1_3_0.conversation.TimelineChannel; +import blue.repo.coordination.TimelineChannel; import java.math.BigInteger; public final class TimelineProviderSupport { @@ -14,7 +14,7 @@ private TimelineProviderSupport() { public static ChannelEvaluation evaluateTimelineEntry(TimelineChannel contract, ChannelEvaluationContext context) { Node eventNode = context.event(); - if (!ConversationEventNodes.isTimelineEntry(eventNode)) { + if (!CoordinationEventNodes.isTimelineEntry(eventNode)) { return ChannelEvaluation.noMatch(); } if (!matchesTimelineId(contract, eventNode) || !matchesEventFilter(contract, eventNode)) { @@ -25,11 +25,12 @@ public static ChannelEvaluation evaluateTimelineEntry(TimelineChannel contract, public static boolean matchesTimelineId(TimelineChannel contract, Node eventNode) { String timelineId = trimToNull(contract.getTimelineId()); - return timelineId == null || timelineId.equals(ConversationEventNodes.timelineId(eventNode)); + return timelineId == null || timelineId.equals(CoordinationEventNodes.timelineId(eventNode)); } public static boolean matchesEventFilter(TimelineChannel contract, Node eventNode) { - return contract.getEvent() == null || ConversationEventNodes.matchesPattern(eventNode, contract.getEvent()); + Node definition = contract.getDefinition(); + return definition == null || CoordinationEventNodes.matchesPattern(eventNode, definition); } public static String eventId(Node eventNode) { @@ -37,14 +38,20 @@ public static String eventId(Node eventNode) { } public static boolean isNewerOrSameTimelineEvent(ChannelCheckpointContext context) { - BigInteger currentTimestamp = ConversationEventNodes.timestamp(context.event()); + Node currentEvent = context.event(); + Node previousEvent = context.lastEvent(); + BigInteger currentTimestamp = CoordinationEventNodes.timestamp(currentEvent); if (currentTimestamp == null) { return true; } - BigInteger previousTimestamp = ConversationEventNodes.timestamp(context.lastEvent()); + BigInteger previousTimestamp = CoordinationEventNodes.timestamp(previousEvent); if (previousTimestamp == null) { return true; } + if (currentTimestamp.compareTo(previousTimestamp) == 0 + && CoordinationEventNodes.matchesPattern(previousEvent, currentEvent)) { + return false; + } return currentTimestamp.compareTo(previousTimestamp) >= 0; } diff --git a/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java b/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java new file mode 100644 index 0000000..f029c26 --- /dev/null +++ b/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java @@ -0,0 +1,1186 @@ +package blue.coordination.processor.bex; + +import blue.bex.result.BexMetrics; +import blue.language.processor.ProcessingMetricsSink; + +import java.util.concurrent.atomic.AtomicLong; + +public final class BexProcessingMetrics implements ProcessingMetricsSink { + private final AtomicLong workflowStepsExecuted = new AtomicLong(); + private final AtomicLong computeStepsExecuted = new AtomicLong(); + private final AtomicLong updateDocumentStepsExecuted = new AtomicLong(); + private final AtomicLong triggerEventStepsExecuted = new AtomicLong(); + private final AtomicLong directBexChangesetHits = new AtomicLong(); + private final AtomicLong bexSyntheticProgramMaterializations = new AtomicLong(); + private final AtomicLong patchesApplied = new AtomicLong(); + private final AtomicLong eventsEmitted = new AtomicLong(); + private final AtomicLong computeProgramNormalizations = new AtomicLong(); + private final AtomicLong computeDefinitionNormalizations = new AtomicLong(); + private final AtomicLong computeDefinitionResolveHits = new AtomicLong(); + private final AtomicLong computeDefinitionResolveMisses = new AtomicLong(); + private final AtomicLong workflowRunnerNanos = new AtomicLong(); + private final AtomicLong computeStepNanos = new AtomicLong(); + private final AtomicLong computeDefinitionResolveNanos = new AtomicLong(); + private final AtomicLong computeContextBuildNanos = new AtomicLong(); + private final AtomicLong computeProgramSourceBuildNanos = new AtomicLong(); + private final AtomicLong computeCompileExecuteNanos = new AtomicLong(); + private final AtomicLong updateStepNanos = new AtomicLong(); + private final AtomicLong updateDirectChangesetNanos = new AtomicLong(); + private final AtomicLong updatePatchConversionNanos = new AtomicLong(); + private final AtomicLong updatePatchApplyNanos = new AtomicLong(); + private final AtomicLong updateBatchPatchApplications = new AtomicLong(); + private final AtomicLong updateIndividualPatchApplications = new AtomicLong(); + private final AtomicLong triggerStepNanos = new AtomicLong(); + private final AtomicLong triggerEmitEventNanos = new AtomicLong(); + private final AtomicLong bexCompileNanos = new AtomicLong(); + private final AtomicLong bexExecuteNanos = new AtomicLong(); + private final AtomicLong bexCompileCacheHits = new AtomicLong(); + private final AtomicLong bexCompileCacheMisses = new AtomicLong(); + private final AtomicLong bexCompiledExecutions = new AtomicLong(); + private final AtomicLong bexNodeWriterNanos = new AtomicLong(); + private final AtomicLong directBexPatchEntryConversions = new AtomicLong(); + private final AtomicLong processDocumentNanos = new AtomicLong(); + private final AtomicLong blueProcessDocumentNanos = new AtomicLong(); + private final AtomicLong eventPreprocessNanos = new AtomicLong(); + private final AtomicLong resultSnapshotAttachNanos = new AtomicLong(); + private final AtomicLong blueIdCalculationNanos = new AtomicLong(); + private final AtomicLong processingSnapshotCacheLookupNanos = new AtomicLong(); + private final AtomicLong processingSnapshotCacheHits = new AtomicLong(); + private final AtomicLong processingSnapshotCacheMisses = new AtomicLong(); + private final AtomicLong processingSnapshotFromDocumentNanos = new AtomicLong(); + private final AtomicLong processingSnapshotFromDocumentBuilds = new AtomicLong(); + private final AtomicLong bundleLoadNanos = new AtomicLong(); + private final AtomicLong bundleLoadCacheKeyBuildNanos = new AtomicLong(); + private final AtomicLong bundleLoadActualBuildNanos = new AtomicLong(); + private final AtomicLong bundleLoadReuseNanos = new AtomicLong(); + private final AtomicLong bundleLoadCacheHits = new AtomicLong(); + private final AtomicLong bundleLoadCacheMisses = new AtomicLong(); + private final AtomicLong bundlesBuilt = new AtomicLong(); + private final AtomicLong bundlesReused = new AtomicLong(); + private final AtomicLong bundleScopeLoadAttempts = new AtomicLong(); + private final AtomicLong bundleScopeExecutionCacheHits = new AtomicLong(); + private final AtomicLong bundleScopeRefreshes = new AtomicLong(); + private final AtomicLong bundleScopeTerminationCheckNanos = new AtomicLong(); + private final AtomicLong bundleScopeResolvedLookupNanos = new AtomicLong(); + private final AtomicLong bundleScopeContractLoadNanos = new AtomicLong(); + private final AtomicLong channelDiscoveryNanos = new AtomicLong(); + private final AtomicLong channelMatchNanos = new AtomicLong(); + private final AtomicLong channelEvaluations = new AtomicLong(); + private final AtomicLong handlerDiscoveryNanos = new AtomicLong(); + private final AtomicLong handlerMatchNanos = new AtomicLong(); + private final AtomicLong handlerMatchAttempts = new AtomicLong(); + private final AtomicLong handlerExecutionNanos = new AtomicLong(); + private final AtomicLong handlersExecuted = new AtomicLong(); + private final AtomicLong triggeredEventRoutingNanos = new AtomicLong(); + private final AtomicLong triggeredEventsRouted = new AtomicLong(); + private final AtomicLong checkpointUpdateNanos = new AtomicLong(); + private final AtomicLong checkpointEnsureNanos = new AtomicLong(); + private final AtomicLong checkpointFindNanos = new AtomicLong(); + private final AtomicLong checkpointCurrentIdentityNanos = new AtomicLong(); + private final AtomicLong checkpointIsNewerNanos = new AtomicLong(); + private final AtomicLong checkpointDuplicateNanos = new AtomicLong(); + private final AtomicLong checkpointPersistNanos = new AtomicLong(); + private final AtomicLong checkpointIdentityCacheHits = new AtomicLong(); + private final AtomicLong checkpointIdentityCacheMisses = new AtomicLong(); + private final AtomicLong checkpointStoredIdentityCacheHits = new AtomicLong(); + private final AtomicLong checkpointStoredIdentityCacheMisses = new AtomicLong(); + private final AtomicLong checkpointDirectBlueIdNanos = new AtomicLong(); + private final AtomicLong checkpointContentBlueIdNanos = new AtomicLong(); + private final AtomicLong checkpointFallbackNanos = new AtomicLong(); + private final AtomicLong snapshotCommitNanos = new AtomicLong(); + private final AtomicLong postProcessingNanos = new AtomicLong(); + private final AtomicLong patchBoundaryNanos = new AtomicLong(); + private final AtomicLong patchGasNanos = new AtomicLong(); + private final AtomicLong documentUpdateRoutingNanos = new AtomicLong(); + private final AtomicLong documentUpdateEventsBuilt = new AtomicLong(); + private final AtomicLong documentUpdateEventsSkippedNoChannel = new AtomicLong(); + private final AtomicLong batchPatchPlanningNanos = new AtomicLong(); + private final AtomicLong batchPatchConformanceNanos = new AtomicLong(); + private final AtomicLong batchPatchBuildUpdatesNanos = new AtomicLong(); + private final AtomicLong batchPatchCommitNanos = new AtomicLong(); + private final AtomicLong documentUpdateBeforeMaterializations = new AtomicLong(); + private final AtomicLong documentUpdateAfterMaterializations = new AtomicLong(); + private final AtomicLong workflowDocumentViewsFromFrozen = new AtomicLong(); + private final AtomicLong workflowDocumentViewsFromDocument = new AtomicLong(); + private final AtomicLong workflowDocumentViewMisses = new AtomicLong(); + private final AtomicLong bexDocumentViewMaterializedHits = new AtomicLong(); + private final AtomicLong bexDocumentViewFrozenDirectHits = new AtomicLong(); + private final AtomicLong bexDocumentViewFrozenRootFallbackHits = new AtomicLong(); + private final AtomicLong bexDocumentViewUndefinedHits = new AtomicLong(); + + public void incrementWorkflowStepsExecuted() { + workflowStepsExecuted.incrementAndGet(); + } + + public void incrementComputeStepsExecuted() { + computeStepsExecuted.incrementAndGet(); + } + + public void incrementUpdateDocumentStepsExecuted() { + updateDocumentStepsExecuted.incrementAndGet(); + } + + public void incrementTriggerEventStepsExecuted() { + triggerEventStepsExecuted.incrementAndGet(); + } + + public void incrementDirectBexChangesetHits() { + directBexChangesetHits.incrementAndGet(); + } + + public void incrementBexSyntheticProgramMaterializations() { + bexSyntheticProgramMaterializations.incrementAndGet(); + } + + public void addPatchesApplied(long count) { + patchesApplied.addAndGet(count); + } + + public void incrementEventsEmitted() { + eventsEmitted.incrementAndGet(); + } + + public void incrementComputeProgramNormalizations() { + computeProgramNormalizations.incrementAndGet(); + } + + public void incrementComputeDefinitionNormalizations() { + computeDefinitionNormalizations.incrementAndGet(); + } + + public void incrementComputeDefinitionResolveHits() { + computeDefinitionResolveHits.incrementAndGet(); + } + + public void incrementComputeDefinitionResolveMisses() { + computeDefinitionResolveMisses.incrementAndGet(); + } + + public void addWorkflowRunnerNanos(long nanos) { + workflowRunnerNanos.addAndGet(nonNegative(nanos)); + } + + public void addComputeStepNanos(long nanos) { + computeStepNanos.addAndGet(nonNegative(nanos)); + } + + public void addComputeDefinitionResolveNanos(long nanos) { + computeDefinitionResolveNanos.addAndGet(nonNegative(nanos)); + } + + public void addComputeContextBuildNanos(long nanos) { + computeContextBuildNanos.addAndGet(nonNegative(nanos)); + } + + public void addComputeProgramSourceBuildNanos(long nanos) { + computeProgramSourceBuildNanos.addAndGet(nonNegative(nanos)); + } + + public void addComputeCompileExecuteNanos(long nanos) { + computeCompileExecuteNanos.addAndGet(nonNegative(nanos)); + } + + public void addUpdateStepNanos(long nanos) { + updateStepNanos.addAndGet(nonNegative(nanos)); + } + + public void addUpdateDirectChangesetNanos(long nanos) { + updateDirectChangesetNanos.addAndGet(nonNegative(nanos)); + } + + public void addUpdatePatchConversionNanos(long nanos) { + updatePatchConversionNanos.addAndGet(nonNegative(nanos)); + } + + public void addUpdatePatchApplyNanos(long nanos) { + updatePatchApplyNanos.addAndGet(nonNegative(nanos)); + } + + public void incrementUpdateBatchPatchApplications() { + updateBatchPatchApplications.incrementAndGet(); + } + + public void incrementUpdateIndividualPatchApplications() { + updateIndividualPatchApplications.incrementAndGet(); + } + + public void addTriggerStepNanos(long nanos) { + triggerStepNanos.addAndGet(nonNegative(nanos)); + } + + public void addTriggerEmitEventNanos(long nanos) { + triggerEmitEventNanos.addAndGet(nonNegative(nanos)); + } + + public void addBexNodeWriterNanos(long nanos) { + bexNodeWriterNanos.addAndGet(nonNegative(nanos)); + } + + public void incrementDirectBexPatchEntryConversions() { + directBexPatchEntryConversions.incrementAndGet(); + } + + public void addBexMetrics(BexMetrics metrics) { + if (metrics == null) { + return; + } + bexCompileCacheHits.addAndGet(metrics.compileCacheHits()); + bexCompileCacheMisses.addAndGet(metrics.compileCacheMisses()); + bexCompiledExecutions.addAndGet(metrics.compiledExecutions()); + bexCompileNanos.addAndGet(metrics.compileNanos()); + bexExecuteNanos.addAndGet(metrics.executeNanos()); + } + + @Override + public void addProcessDocumentNanos(long nanos) { + processDocumentNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBlueProcessDocumentNanos(long nanos) { + blueProcessDocumentNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addEventPreprocessNanos(long nanos) { + eventPreprocessNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addResultSnapshotAttachNanos(long nanos) { + resultSnapshotAttachNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBlueIdCalculationNanos(long nanos) { + blueIdCalculationNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addProcessingSnapshotCacheLookupNanos(long nanos) { + processingSnapshotCacheLookupNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementProcessingSnapshotCacheHits() { + processingSnapshotCacheHits.incrementAndGet(); + } + + @Override + public void incrementProcessingSnapshotCacheMisses() { + processingSnapshotCacheMisses.incrementAndGet(); + } + + @Override + public void addProcessingSnapshotFromDocumentNanos(long nanos) { + processingSnapshotFromDocumentNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementProcessingSnapshotFromDocumentBuilds() { + processingSnapshotFromDocumentBuilds.incrementAndGet(); + } + + @Override + public void addBundleLoadNanos(long nanos) { + bundleLoadNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBundleLoadCacheKeyBuildNanos(long nanos) { + bundleLoadCacheKeyBuildNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBundleLoadActualBuildNanos(long nanos) { + bundleLoadActualBuildNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBundleLoadReuseNanos(long nanos) { + bundleLoadReuseNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementBundleLoadCacheHits() { + bundleLoadCacheHits.incrementAndGet(); + } + + @Override + public void incrementBundleLoadCacheMisses() { + bundleLoadCacheMisses.incrementAndGet(); + } + + @Override + public void incrementBundlesBuilt() { + bundlesBuilt.incrementAndGet(); + } + + @Override + public void incrementBundlesReused() { + bundlesReused.incrementAndGet(); + } + + @Override + public void incrementBundleScopeLoadAttempts() { + bundleScopeLoadAttempts.incrementAndGet(); + } + + @Override + public void incrementBundleScopeExecutionCacheHits() { + bundleScopeExecutionCacheHits.incrementAndGet(); + } + + @Override + public void incrementBundleScopeRefreshes() { + bundleScopeRefreshes.incrementAndGet(); + } + + @Override + public void addBundleScopeTerminationCheckNanos(long nanos) { + bundleScopeTerminationCheckNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBundleScopeResolvedLookupNanos(long nanos) { + bundleScopeResolvedLookupNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBundleScopeContractLoadNanos(long nanos) { + bundleScopeContractLoadNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addChannelDiscoveryNanos(long nanos) { + channelDiscoveryNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addChannelMatchNanos(long nanos) { + channelMatchNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementChannelEvaluations() { + channelEvaluations.incrementAndGet(); + } + + @Override + public void addHandlerDiscoveryNanos(long nanos) { + handlerDiscoveryNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addHandlerMatchNanos(long nanos) { + handlerMatchNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementHandlerMatchAttempts() { + handlerMatchAttempts.incrementAndGet(); + } + + @Override + public void addHandlerExecutionNanos(long nanos) { + handlerExecutionNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementHandlersExecuted() { + handlersExecuted.incrementAndGet(); + } + + @Override + public void addTriggeredEventRoutingNanos(long nanos) { + triggeredEventRoutingNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementTriggeredEventsRouted() { + triggeredEventsRouted.incrementAndGet(); + } + + @Override + public void addCheckpointUpdateNanos(long nanos) { + checkpointUpdateNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointEnsureNanos(long nanos) { + checkpointEnsureNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointFindNanos(long nanos) { + checkpointFindNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointCurrentIdentityNanos(long nanos) { + checkpointCurrentIdentityNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointIsNewerNanos(long nanos) { + checkpointIsNewerNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointDuplicateNanos(long nanos) { + checkpointDuplicateNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointPersistNanos(long nanos) { + checkpointPersistNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementCheckpointIdentityCacheHits() { + checkpointIdentityCacheHits.incrementAndGet(); + } + + @Override + public void incrementCheckpointIdentityCacheMisses() { + checkpointIdentityCacheMisses.incrementAndGet(); + } + + @Override + public void incrementCheckpointStoredIdentityCacheHits() { + checkpointStoredIdentityCacheHits.incrementAndGet(); + } + + @Override + public void incrementCheckpointStoredIdentityCacheMisses() { + checkpointStoredIdentityCacheMisses.incrementAndGet(); + } + + @Override + public void addCheckpointDirectBlueIdNanos(long nanos) { + checkpointDirectBlueIdNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointContentBlueIdNanos(long nanos) { + checkpointContentBlueIdNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addCheckpointFallbackNanos(long nanos) { + checkpointFallbackNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addSnapshotCommitNanos(long nanos) { + snapshotCommitNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addPostProcessingNanos(long nanos) { + postProcessingNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addPatchBoundaryNanos(long nanos) { + patchBoundaryNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addPatchGasNanos(long nanos) { + patchGasNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addDocumentUpdateRoutingNanos(long nanos) { + documentUpdateRoutingNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementDocumentUpdateEventsBuilt() { + documentUpdateEventsBuilt.incrementAndGet(); + } + + @Override + public void incrementDocumentUpdateEventsSkippedNoChannel() { + documentUpdateEventsSkippedNoChannel.incrementAndGet(); + } + + @Override + public void addBatchPatchPlanningNanos(long nanos) { + batchPatchPlanningNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBatchPatchConformanceNanos(long nanos) { + batchPatchConformanceNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBatchPatchBuildUpdatesNanos(long nanos) { + batchPatchBuildUpdatesNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addBatchPatchCommitNanos(long nanos) { + batchPatchCommitNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementDocumentUpdateBeforeMaterializations() { + documentUpdateBeforeMaterializations.incrementAndGet(); + } + + @Override + public void incrementDocumentUpdateAfterMaterializations() { + documentUpdateAfterMaterializations.incrementAndGet(); + } + + public void incrementWorkflowDocumentViewsFromFrozen() { + workflowDocumentViewsFromFrozen.incrementAndGet(); + } + + public void incrementWorkflowDocumentViewsFromDocument() { + workflowDocumentViewsFromDocument.incrementAndGet(); + } + + public void incrementWorkflowDocumentViewMisses() { + workflowDocumentViewMisses.incrementAndGet(); + } + + public void incrementBexDocumentViewMaterializedHits() { + bexDocumentViewMaterializedHits.incrementAndGet(); + } + + public void incrementBexDocumentViewFrozenDirectHits() { + bexDocumentViewFrozenDirectHits.incrementAndGet(); + } + + public void incrementBexDocumentViewFrozenRootFallbackHits() { + bexDocumentViewFrozenRootFallbackHits.incrementAndGet(); + } + + public void incrementBexDocumentViewUndefinedHits() { + bexDocumentViewUndefinedHits.incrementAndGet(); + } + + public long workflowStepsExecuted() { + return workflowStepsExecuted.get(); + } + + public long computeStepsExecuted() { + return computeStepsExecuted.get(); + } + + public long updateDocumentStepsExecuted() { + return updateDocumentStepsExecuted.get(); + } + + public long triggerEventStepsExecuted() { + return triggerEventStepsExecuted.get(); + } + + public long directBexChangesetHits() { + return directBexChangesetHits.get(); + } + + public long bexSyntheticProgramMaterializations() { + return bexSyntheticProgramMaterializations.get(); + } + + public long patchesApplied() { + return patchesApplied.get(); + } + + public long eventsEmitted() { + return eventsEmitted.get(); + } + + public long computeProgramNormalizations() { + return computeProgramNormalizations.get(); + } + + public long computeDefinitionNormalizations() { + return computeDefinitionNormalizations.get(); + } + + public long computeDefinitionResolveHits() { + return computeDefinitionResolveHits.get(); + } + + public long computeDefinitionResolveMisses() { + return computeDefinitionResolveMisses.get(); + } + + public long workflowRunnerNanos() { + return workflowRunnerNanos.get(); + } + + public long computeStepNanos() { + return computeStepNanos.get(); + } + + public long computeDefinitionResolveNanos() { + return computeDefinitionResolveNanos.get(); + } + + public long computeContextBuildNanos() { + return computeContextBuildNanos.get(); + } + + public long computeProgramSourceBuildNanos() { + return computeProgramSourceBuildNanos.get(); + } + + public long computeCompileExecuteNanos() { + return computeCompileExecuteNanos.get(); + } + + public long updateStepNanos() { + return updateStepNanos.get(); + } + + public long updateDirectChangesetNanos() { + return updateDirectChangesetNanos.get(); + } + + public long updatePatchConversionNanos() { + return updatePatchConversionNanos.get(); + } + + public long updatePatchApplyNanos() { + return updatePatchApplyNanos.get(); + } + + public long updateBatchPatchApplications() { + return updateBatchPatchApplications.get(); + } + + public long updateIndividualPatchApplications() { + return updateIndividualPatchApplications.get(); + } + + public long triggerStepNanos() { + return triggerStepNanos.get(); + } + + public long triggerEmitEventNanos() { + return triggerEmitEventNanos.get(); + } + + public long bexCompileNanos() { + return bexCompileNanos.get(); + } + + public long bexExecuteNanos() { + return bexExecuteNanos.get(); + } + + public long bexCompileCacheHits() { + return bexCompileCacheHits.get(); + } + + public long bexCompileCacheMisses() { + return bexCompileCacheMisses.get(); + } + + public long bexCompiledExecutions() { + return bexCompiledExecutions.get(); + } + + public long bexNodeWriterNanos() { + return bexNodeWriterNanos.get(); + } + + public long directBexPatchEntryConversions() { + return directBexPatchEntryConversions.get(); + } + + public long processDocumentNanos() { + return processDocumentNanos.get(); + } + + public long blueProcessDocumentNanos() { + return blueProcessDocumentNanos.get(); + } + + public long eventPreprocessNanos() { + return eventPreprocessNanos.get(); + } + + public long resultSnapshotAttachNanos() { + return resultSnapshotAttachNanos.get(); + } + + public long blueIdCalculationNanos() { + return blueIdCalculationNanos.get(); + } + + public long processingSnapshotCacheLookupNanos() { + return processingSnapshotCacheLookupNanos.get(); + } + + public long processingSnapshotCacheHits() { + return processingSnapshotCacheHits.get(); + } + + public long processingSnapshotCacheMisses() { + return processingSnapshotCacheMisses.get(); + } + + public long processingSnapshotFromDocumentNanos() { + return processingSnapshotFromDocumentNanos.get(); + } + + public long processingSnapshotFromDocumentBuilds() { + return processingSnapshotFromDocumentBuilds.get(); + } + + public long bundleLoadNanos() { + return bundleLoadNanos.get(); + } + + public long bundleLoadCacheKeyBuildNanos() { + return bundleLoadCacheKeyBuildNanos.get(); + } + + public long bundleLoadActualBuildNanos() { + return bundleLoadActualBuildNanos.get(); + } + + public long bundleLoadReuseNanos() { + return bundleLoadReuseNanos.get(); + } + + public long bundleLoadCacheHits() { + return bundleLoadCacheHits.get(); + } + + public long bundleLoadCacheMisses() { + return bundleLoadCacheMisses.get(); + } + + public long bundlesBuilt() { + return bundlesBuilt.get(); + } + + public long bundlesReused() { + return bundlesReused.get(); + } + + public long bundleScopeLoadAttempts() { + return bundleScopeLoadAttempts.get(); + } + + public long bundleScopeExecutionCacheHits() { + return bundleScopeExecutionCacheHits.get(); + } + + public long bundleScopeRefreshes() { + return bundleScopeRefreshes.get(); + } + + public long bundleScopeTerminationCheckNanos() { + return bundleScopeTerminationCheckNanos.get(); + } + + public long bundleScopeResolvedLookupNanos() { + return bundleScopeResolvedLookupNanos.get(); + } + + public long bundleScopeContractLoadNanos() { + return bundleScopeContractLoadNanos.get(); + } + + public long channelDiscoveryNanos() { + return channelDiscoveryNanos.get(); + } + + public long channelMatchNanos() { + return channelMatchNanos.get(); + } + + public long channelEvaluations() { + return channelEvaluations.get(); + } + + public long handlerDiscoveryNanos() { + return handlerDiscoveryNanos.get(); + } + + public long handlerMatchNanos() { + return handlerMatchNanos.get(); + } + + public long handlerMatchAttempts() { + return handlerMatchAttempts.get(); + } + + public long handlerExecutionNanos() { + return handlerExecutionNanos.get(); + } + + public long handlersExecuted() { + return handlersExecuted.get(); + } + + public long triggeredEventRoutingNanos() { + return triggeredEventRoutingNanos.get(); + } + + public long triggeredEventsRouted() { + return triggeredEventsRouted.get(); + } + + public long checkpointUpdateNanos() { + return checkpointUpdateNanos.get(); + } + + public long checkpointEnsureNanos() { + return checkpointEnsureNanos.get(); + } + + public long checkpointFindNanos() { + return checkpointFindNanos.get(); + } + + public long checkpointCurrentIdentityNanos() { + return checkpointCurrentIdentityNanos.get(); + } + + public long checkpointIsNewerNanos() { + return checkpointIsNewerNanos.get(); + } + + public long checkpointDuplicateNanos() { + return checkpointDuplicateNanos.get(); + } + + public long checkpointPersistNanos() { + return checkpointPersistNanos.get(); + } + + public long checkpointIdentityCacheHits() { + return checkpointIdentityCacheHits.get(); + } + + public long checkpointIdentityCacheMisses() { + return checkpointIdentityCacheMisses.get(); + } + + public long checkpointStoredIdentityCacheHits() { + return checkpointStoredIdentityCacheHits.get(); + } + + public long checkpointStoredIdentityCacheMisses() { + return checkpointStoredIdentityCacheMisses.get(); + } + + public long checkpointDirectBlueIdNanos() { + return checkpointDirectBlueIdNanos.get(); + } + + public long checkpointContentBlueIdNanos() { + return checkpointContentBlueIdNanos.get(); + } + + public long checkpointFallbackNanos() { + return checkpointFallbackNanos.get(); + } + + public long snapshotCommitNanos() { + return snapshotCommitNanos.get(); + } + + public long postProcessingNanos() { + return postProcessingNanos.get(); + } + + public long patchBoundaryNanos() { + return patchBoundaryNanos.get(); + } + + public long patchGasNanos() { + return patchGasNanos.get(); + } + + public long documentUpdateRoutingNanos() { + return documentUpdateRoutingNanos.get(); + } + + public long documentUpdateEventsBuilt() { + return documentUpdateEventsBuilt.get(); + } + + public long documentUpdateEventsSkippedNoChannel() { + return documentUpdateEventsSkippedNoChannel.get(); + } + + public long batchPatchPlanningNanos() { + return batchPatchPlanningNanos.get(); + } + + public long batchPatchConformanceNanos() { + return batchPatchConformanceNanos.get(); + } + + public long batchPatchBuildUpdatesNanos() { + return batchPatchBuildUpdatesNanos.get(); + } + + public long batchPatchCommitNanos() { + return batchPatchCommitNanos.get(); + } + + public long documentUpdateBeforeMaterializations() { + return documentUpdateBeforeMaterializations.get(); + } + + public long documentUpdateAfterMaterializations() { + return documentUpdateAfterMaterializations.get(); + } + + public long workflowDocumentViewsFromFrozen() { + return workflowDocumentViewsFromFrozen.get(); + } + + public long workflowDocumentViewsFromDocument() { + return workflowDocumentViewsFromDocument.get(); + } + + public long workflowDocumentViewMisses() { + return workflowDocumentViewMisses.get(); + } + + public long bexDocumentViewMaterializedHits() { + return bexDocumentViewMaterializedHits.get(); + } + + public long bexDocumentViewFrozenDirectHits() { + return bexDocumentViewFrozenDirectHits.get(); + } + + public long bexDocumentViewFrozenRootFallbackHits() { + return bexDocumentViewFrozenRootFallbackHits.get(); + } + + public long bexDocumentViewUndefinedHits() { + return bexDocumentViewUndefinedHits.get(); + } + + public Snapshot snapshot() { + return new Snapshot(this); + } + + private static long nonNegative(long nanos) { + return nanos > 0L ? nanos : 0L; + } + + public static final class Snapshot { + public final long workflowStepsExecuted; + public final long computeStepsExecuted; + public final long updateDocumentStepsExecuted; + public final long triggerEventStepsExecuted; + public final long directBexChangesetHits; + public final long bexSyntheticProgramMaterializations; + public final long patchesApplied; + public final long eventsEmitted; + public final long computeProgramNormalizations; + public final long computeDefinitionNormalizations; + public final long computeDefinitionResolveHits; + public final long computeDefinitionResolveMisses; + public final long workflowRunnerNanos; + public final long computeStepNanos; + public final long computeDefinitionResolveNanos; + public final long computeContextBuildNanos; + public final long computeProgramSourceBuildNanos; + public final long computeCompileExecuteNanos; + public final long updateStepNanos; + public final long updateDirectChangesetNanos; + public final long updatePatchConversionNanos; + public final long updatePatchApplyNanos; + public final long updateBatchPatchApplications; + public final long updateIndividualPatchApplications; + public final long triggerStepNanos; + public final long triggerEmitEventNanos; + public final long bexCompileNanos; + public final long bexExecuteNanos; + public final long bexCompileCacheHits; + public final long bexCompileCacheMisses; + public final long bexCompiledExecutions; + public final long bexNodeWriterNanos; + public final long directBexPatchEntryConversions; + public final long processDocumentNanos; + public final long blueProcessDocumentNanos; + public final long eventPreprocessNanos; + public final long resultSnapshotAttachNanos; + public final long blueIdCalculationNanos; + public final long processingSnapshotCacheLookupNanos; + public final long processingSnapshotCacheHits; + public final long processingSnapshotCacheMisses; + public final long processingSnapshotFromDocumentNanos; + public final long processingSnapshotFromDocumentBuilds; + public final long bundleLoadNanos; + public final long bundleLoadCacheKeyBuildNanos; + public final long bundleLoadActualBuildNanos; + public final long bundleLoadReuseNanos; + public final long bundleLoadCacheHits; + public final long bundleLoadCacheMisses; + public final long bundlesBuilt; + public final long bundlesReused; + public final long bundleScopeLoadAttempts; + public final long bundleScopeExecutionCacheHits; + public final long bundleScopeRefreshes; + public final long bundleScopeTerminationCheckNanos; + public final long bundleScopeResolvedLookupNanos; + public final long bundleScopeContractLoadNanos; + public final long channelDiscoveryNanos; + public final long channelMatchNanos; + public final long channelEvaluations; + public final long handlerDiscoveryNanos; + public final long handlerMatchNanos; + public final long handlerMatchAttempts; + public final long handlerExecutionNanos; + public final long handlersExecuted; + public final long triggeredEventRoutingNanos; + public final long triggeredEventsRouted; + public final long checkpointUpdateNanos; + public final long checkpointEnsureNanos; + public final long checkpointFindNanos; + public final long checkpointCurrentIdentityNanos; + public final long checkpointIsNewerNanos; + public final long checkpointDuplicateNanos; + public final long checkpointPersistNanos; + public final long checkpointIdentityCacheHits; + public final long checkpointIdentityCacheMisses; + public final long checkpointStoredIdentityCacheHits; + public final long checkpointStoredIdentityCacheMisses; + public final long checkpointDirectBlueIdNanos; + public final long checkpointContentBlueIdNanos; + public final long checkpointFallbackNanos; + public final long snapshotCommitNanos; + public final long postProcessingNanos; + public final long patchBoundaryNanos; + public final long patchGasNanos; + public final long documentUpdateRoutingNanos; + public final long documentUpdateEventsBuilt; + public final long documentUpdateEventsSkippedNoChannel; + public final long batchPatchPlanningNanos; + public final long batchPatchConformanceNanos; + public final long batchPatchBuildUpdatesNanos; + public final long batchPatchCommitNanos; + public final long documentUpdateBeforeMaterializations; + public final long documentUpdateAfterMaterializations; + public final long workflowDocumentViewsFromFrozen; + public final long workflowDocumentViewsFromDocument; + public final long workflowDocumentViewMisses; + public final long bexDocumentViewMaterializedHits; + public final long bexDocumentViewFrozenDirectHits; + public final long bexDocumentViewFrozenRootFallbackHits; + public final long bexDocumentViewUndefinedHits; + + private Snapshot(BexProcessingMetrics metrics) { + this.workflowStepsExecuted = metrics.workflowStepsExecuted(); + this.computeStepsExecuted = metrics.computeStepsExecuted(); + this.updateDocumentStepsExecuted = metrics.updateDocumentStepsExecuted(); + this.triggerEventStepsExecuted = metrics.triggerEventStepsExecuted(); + this.directBexChangesetHits = metrics.directBexChangesetHits(); + this.bexSyntheticProgramMaterializations = metrics.bexSyntheticProgramMaterializations(); + this.patchesApplied = metrics.patchesApplied(); + this.eventsEmitted = metrics.eventsEmitted(); + this.computeProgramNormalizations = metrics.computeProgramNormalizations(); + this.computeDefinitionNormalizations = metrics.computeDefinitionNormalizations(); + this.computeDefinitionResolveHits = metrics.computeDefinitionResolveHits(); + this.computeDefinitionResolveMisses = metrics.computeDefinitionResolveMisses(); + this.workflowRunnerNanos = metrics.workflowRunnerNanos(); + this.computeStepNanos = metrics.computeStepNanos(); + this.computeDefinitionResolveNanos = metrics.computeDefinitionResolveNanos(); + this.computeContextBuildNanos = metrics.computeContextBuildNanos(); + this.computeProgramSourceBuildNanos = metrics.computeProgramSourceBuildNanos(); + this.computeCompileExecuteNanos = metrics.computeCompileExecuteNanos(); + this.updateStepNanos = metrics.updateStepNanos(); + this.updateDirectChangesetNanos = metrics.updateDirectChangesetNanos(); + this.updatePatchConversionNanos = metrics.updatePatchConversionNanos(); + this.updatePatchApplyNanos = metrics.updatePatchApplyNanos(); + this.updateBatchPatchApplications = metrics.updateBatchPatchApplications(); + this.updateIndividualPatchApplications = metrics.updateIndividualPatchApplications(); + this.triggerStepNanos = metrics.triggerStepNanos(); + this.triggerEmitEventNanos = metrics.triggerEmitEventNanos(); + this.bexCompileNanos = metrics.bexCompileNanos(); + this.bexExecuteNanos = metrics.bexExecuteNanos(); + this.bexCompileCacheHits = metrics.bexCompileCacheHits(); + this.bexCompileCacheMisses = metrics.bexCompileCacheMisses(); + this.bexCompiledExecutions = metrics.bexCompiledExecutions(); + this.bexNodeWriterNanos = metrics.bexNodeWriterNanos(); + this.directBexPatchEntryConversions = metrics.directBexPatchEntryConversions(); + this.processDocumentNanos = metrics.processDocumentNanos(); + this.blueProcessDocumentNanos = metrics.blueProcessDocumentNanos(); + this.eventPreprocessNanos = metrics.eventPreprocessNanos(); + this.resultSnapshotAttachNanos = metrics.resultSnapshotAttachNanos(); + this.blueIdCalculationNanos = metrics.blueIdCalculationNanos(); + this.processingSnapshotCacheLookupNanos = metrics.processingSnapshotCacheLookupNanos(); + this.processingSnapshotCacheHits = metrics.processingSnapshotCacheHits(); + this.processingSnapshotCacheMisses = metrics.processingSnapshotCacheMisses(); + this.processingSnapshotFromDocumentNanos = metrics.processingSnapshotFromDocumentNanos(); + this.processingSnapshotFromDocumentBuilds = metrics.processingSnapshotFromDocumentBuilds(); + this.bundleLoadNanos = metrics.bundleLoadNanos(); + this.bundleLoadCacheKeyBuildNanos = metrics.bundleLoadCacheKeyBuildNanos(); + this.bundleLoadActualBuildNanos = metrics.bundleLoadActualBuildNanos(); + this.bundleLoadReuseNanos = metrics.bundleLoadReuseNanos(); + this.bundleLoadCacheHits = metrics.bundleLoadCacheHits(); + this.bundleLoadCacheMisses = metrics.bundleLoadCacheMisses(); + this.bundlesBuilt = metrics.bundlesBuilt(); + this.bundlesReused = metrics.bundlesReused(); + this.bundleScopeLoadAttempts = metrics.bundleScopeLoadAttempts(); + this.bundleScopeExecutionCacheHits = metrics.bundleScopeExecutionCacheHits(); + this.bundleScopeRefreshes = metrics.bundleScopeRefreshes(); + this.bundleScopeTerminationCheckNanos = metrics.bundleScopeTerminationCheckNanos(); + this.bundleScopeResolvedLookupNanos = metrics.bundleScopeResolvedLookupNanos(); + this.bundleScopeContractLoadNanos = metrics.bundleScopeContractLoadNanos(); + this.channelDiscoveryNanos = metrics.channelDiscoveryNanos(); + this.channelMatchNanos = metrics.channelMatchNanos(); + this.channelEvaluations = metrics.channelEvaluations(); + this.handlerDiscoveryNanos = metrics.handlerDiscoveryNanos(); + this.handlerMatchNanos = metrics.handlerMatchNanos(); + this.handlerMatchAttempts = metrics.handlerMatchAttempts(); + this.handlerExecutionNanos = metrics.handlerExecutionNanos(); + this.handlersExecuted = metrics.handlersExecuted(); + this.triggeredEventRoutingNanos = metrics.triggeredEventRoutingNanos(); + this.triggeredEventsRouted = metrics.triggeredEventsRouted(); + this.checkpointUpdateNanos = metrics.checkpointUpdateNanos(); + this.checkpointEnsureNanos = metrics.checkpointEnsureNanos(); + this.checkpointFindNanos = metrics.checkpointFindNanos(); + this.checkpointCurrentIdentityNanos = metrics.checkpointCurrentIdentityNanos(); + this.checkpointIsNewerNanos = metrics.checkpointIsNewerNanos(); + this.checkpointDuplicateNanos = metrics.checkpointDuplicateNanos(); + this.checkpointPersistNanos = metrics.checkpointPersistNanos(); + this.checkpointIdentityCacheHits = metrics.checkpointIdentityCacheHits(); + this.checkpointIdentityCacheMisses = metrics.checkpointIdentityCacheMisses(); + this.checkpointStoredIdentityCacheHits = metrics.checkpointStoredIdentityCacheHits(); + this.checkpointStoredIdentityCacheMisses = metrics.checkpointStoredIdentityCacheMisses(); + this.checkpointDirectBlueIdNanos = metrics.checkpointDirectBlueIdNanos(); + this.checkpointContentBlueIdNanos = metrics.checkpointContentBlueIdNanos(); + this.checkpointFallbackNanos = metrics.checkpointFallbackNanos(); + this.snapshotCommitNanos = metrics.snapshotCommitNanos(); + this.postProcessingNanos = metrics.postProcessingNanos(); + this.patchBoundaryNanos = metrics.patchBoundaryNanos(); + this.patchGasNanos = metrics.patchGasNanos(); + this.documentUpdateRoutingNanos = metrics.documentUpdateRoutingNanos(); + this.documentUpdateEventsBuilt = metrics.documentUpdateEventsBuilt(); + this.documentUpdateEventsSkippedNoChannel = metrics.documentUpdateEventsSkippedNoChannel(); + this.batchPatchPlanningNanos = metrics.batchPatchPlanningNanos(); + this.batchPatchConformanceNanos = metrics.batchPatchConformanceNanos(); + this.batchPatchBuildUpdatesNanos = metrics.batchPatchBuildUpdatesNanos(); + this.batchPatchCommitNanos = metrics.batchPatchCommitNanos(); + this.documentUpdateBeforeMaterializations = metrics.documentUpdateBeforeMaterializations(); + this.documentUpdateAfterMaterializations = metrics.documentUpdateAfterMaterializations(); + this.workflowDocumentViewsFromFrozen = metrics.workflowDocumentViewsFromFrozen(); + this.workflowDocumentViewsFromDocument = metrics.workflowDocumentViewsFromDocument(); + this.workflowDocumentViewMisses = metrics.workflowDocumentViewMisses(); + this.bexDocumentViewMaterializedHits = metrics.bexDocumentViewMaterializedHits(); + this.bexDocumentViewFrozenDirectHits = metrics.bexDocumentViewFrozenDirectHits(); + this.bexDocumentViewFrozenRootFallbackHits = metrics.bexDocumentViewFrozenRootFallbackHits(); + this.bexDocumentViewUndefinedHits = metrics.bexDocumentViewUndefinedHits(); + } + } +} diff --git a/src/main/java/blue/coordination/processor/bex/BexWorkflowContextFactory.java b/src/main/java/blue/coordination/processor/bex/BexWorkflowContextFactory.java new file mode 100644 index 0000000..05a017c --- /dev/null +++ b/src/main/java/blue/coordination/processor/bex/BexWorkflowContextFactory.java @@ -0,0 +1,77 @@ +package blue.coordination.processor.bex; + +import blue.bex.api.BexExecutionContext; +import blue.bex.api.BexStepResults; +import blue.bex.result.BexExecutionResult; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; +import blue.coordination.processor.workflow.StepExecutionContext; +import blue.language.model.Node; + +import java.util.Map; + +public final class BexWorkflowContextFactory { + private final BexProcessingMetrics metrics; + + public BexWorkflowContextFactory() { + this(null); + } + + public BexWorkflowContextFactory(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + BexProcessingMetrics metrics() { + return metrics; + } + + public BexExecutionContext create(StepExecutionContext context, long gasLimit) { + BexValue event = BexValues.nodeCursorTrustedImmutable(context.eventRef()); + BexValue currentContract = currentContractBinding(context); + BexStepResults steps = stepResults(context.stepResults()); + return BexExecutionContext.builder() + .document(new ScopedProcessorExecutionContextBexDocumentView(context, metrics)) + .event(event) + .currentContract(currentContract) + .steps(steps) + .binding("event", event) + .binding("steps", steps.asValue()) + .binding("currentContract", currentContract) + .gasLimit(gasLimit) + .build(); + } + + public BexStepResults stepResults(Map workflowStepResults) { + BexStepResults.Builder builder = BexStepResults.builder(); + if (workflowStepResults == null) { + return builder.build(); + } + for (Map.Entry entry : workflowStepResults.entrySet()) { + String name = entry.getKey(); + Object value = entry.getValue(); + if (value instanceof BexExecutionResult) { + builder.put(name, (BexExecutionResult) value); + } else if (value instanceof Node) { + builder.put(name, BexValues.nodeCursorTrustedImmutable((Node) value)); + } else { + builder.put(name, BexValues.fromSimple(value)); + } + } + return builder.build(); + } + + public BexValue currentContractBinding(StepExecutionContext context) { + BexValue base = context.currentContractFrozenNode() != null + ? BexValues.frozen(context.currentContractFrozenNode()) + : BexValues.nodeCursorTrustedImmutable(context.currentContractNodeRef()); + String channel = context.workflow().getChannelKey(); + if (channel == null || channel.trim().isEmpty()) { + return base; + } + BexValue existing = base.get("channel"); + if (!existing.isUndefined() && existing.isScalar() && !existing.asText().trim().isEmpty()) { + return base; + } + return BexValues.overlay(base, "channel", BexValues.scalar(channel.trim())); + } +} diff --git a/src/main/java/blue/coordination/processor/bex/ScopedProcessorExecutionContextBexDocumentView.java b/src/main/java/blue/coordination/processor/bex/ScopedProcessorExecutionContextBexDocumentView.java new file mode 100644 index 0000000..3e4986c --- /dev/null +++ b/src/main/java/blue/coordination/processor/bex/ScopedProcessorExecutionContextBexDocumentView.java @@ -0,0 +1,85 @@ +package blue.coordination.processor.bex; + +import blue.bex.api.BexDocumentView; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; +import blue.coordination.processor.workflow.StepExecutionContext; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.JsonPointer; + +import java.util.Objects; + +/** + * BEX document view that resolves authored pointers against the active processor scope. + */ +final class ScopedProcessorExecutionContextBexDocumentView implements BexDocumentView { + private final StepExecutionContext stepContext; + private final ProcessorExecutionContext context; + private final BexProcessingMetrics metrics; + + ScopedProcessorExecutionContextBexDocumentView(StepExecutionContext context) { + this(context, null); + } + + ScopedProcessorExecutionContextBexDocumentView(StepExecutionContext context, + BexProcessingMetrics metrics) { + this.stepContext = Objects.requireNonNull(context, "context"); + this.context = context.processorContext(); + this.metrics = metrics; + } + + @Override + public String resolvePointer(String authoredPointer) { + return context.resolvePointer(authoredPointer); + } + + @Override + public BexValue canonicalAt(String pointer) { + return frozenAt(context.resolvePointer(pointer), true); + } + + @Override + public BexValue resolvedAt(String pointer) { + return frozenAt(context.resolvePointer(pointer), false); + } + + @Override + public String currentScopePath() { + return context.scopePath(); + } + + private BexValue frozenAt(String absolutePointer, boolean canonical) { + FrozenNode viewed = canonical + ? stepContext.workingCanonicalAt(absolutePointer) + : stepContext.workingResolvedAt(absolutePointer); + if (viewed != null) { + if (metrics != null) { + metrics.incrementBexDocumentViewFrozenDirectHits(); + } + return BexValues.frozen(viewed); + } + FrozenNode selected = canonical + ? context.canonicalFrozenAt(absolutePointer) + : context.resolvedFrozenAt(absolutePointer); + if (selected != null) { + if (metrics != null) { + metrics.incrementBexDocumentViewFrozenDirectHits(); + } + return BexValues.frozen(selected); + } + FrozenNode root = canonical + ? stepContext.workingDocument().canonicalRoot() + : stepContext.workingDocument().resolvedRoot(); + if (root != null) { + if (metrics != null) { + metrics.incrementBexDocumentViewFrozenRootFallbackHits(); + } + return BexValues.frozen(root).at(JsonPointer.split(absolutePointer)); + } + if (metrics != null) { + metrics.incrementBexDocumentViewUndefinedHits(); + } + return BexValues.undefined(); + } +} diff --git a/src/main/java/blue/coordination/processor/merge/ComputeRuntimeDefaultMergingProcessor.java b/src/main/java/blue/coordination/processor/merge/ComputeRuntimeDefaultMergingProcessor.java new file mode 100644 index 0000000..81cf48e --- /dev/null +++ b/src/main/java/blue/coordination/processor/merge/ComputeRuntimeDefaultMergingProcessor.java @@ -0,0 +1,228 @@ +package blue.coordination.processor.merge; + +import blue.language.NodeProvider; +import blue.language.merge.MergingProcessor; +import blue.language.merge.NodeResolver; +import blue.language.model.Node; +import blue.language.utils.NodePathAccessor; +import blue.language.utils.NodePathEditor; +import blue.repo.coordination.Compute; +import blue.repo.coordination.ComputeDefinition; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class ComputeRuntimeDefaultMergingProcessor implements MergingProcessor { + private final MergingProcessor delegate; + private final ThreadLocal>> suppressedComputeFields = + ThreadLocal.withInitial(IdentityHashMap::new); + + ComputeRuntimeDefaultMergingProcessor(MergingProcessor delegate) { + if (delegate == null) { + throw new IllegalArgumentException("delegate must not be null"); + } + this.delegate = delegate; + } + + @Override + public void process(Node target, Node source, NodeProvider nodeProvider, NodeResolver nodeResolver) { + stripComputeRuntimeDefaults(target, source); + List paths = computeProgramFieldPaths(source); + preserveComputeFields(target, source, paths); + delegate.process(target, source, nodeProvider, nodeResolver); + suppressComputeFields(source, paths); + } + + @Override + public void postProcess(Node target, Node source, NodeProvider nodeProvider, NodeResolver nodeResolver) { + stripComputeRuntimeDefaults(target, source); + Map> suppressedByNode = suppressedComputeFields.get(); + Map suppressed = suppressedByNode.remove(source); + try { + delegate.postProcess(target, source, nodeProvider, nodeResolver); + } finally { + restoreComputeFields(source, suppressed); + if (suppressedByNode.isEmpty()) { + suppressedComputeFields.remove(); + } + } + preserveComputeFields(target, source, suppressed); + preserveAuthoredMetadata(target, source); + } + + private List computeProgramFieldPaths(Node source) { + if (!isComputeNode(source)) { + return java.util.Collections.emptyList(); + } + List paths = new ArrayList(4); + addIfContainsBex(source, paths, "expr"); + addIfContainsBex(source, paths, "do"); + addIfContainsBex(source, paths, "constants"); + addIfContainsBex(source, paths, "functions"); + return paths; + } + + private void addIfContainsBex(Node node, List paths, String key) { + Node value = property(node, key); + if (containsBexOperator(value)) { + paths.add("/" + key); + } + } + + private boolean containsBexOperator(Node node) { + if (node == null) { + return false; + } + Map properties = node.getProperties(); + if (properties != null) { + if (properties.size() == 1) { + String key = properties.keySet().iterator().next(); + if (key != null && key.startsWith("$")) { + return true; + } + } + for (Node child : properties.values()) { + if (containsBexOperator(child)) { + return true; + } + } + } + if (node.getItems() != null) { + for (Node item : node.getItems()) { + if (containsBexOperator(item)) { + return true; + } + } + } + return false; + } + + private void preserveComputeFields(Node target, Node source, List paths) { + if (paths == null || paths.isEmpty()) { + return; + } + for (String path : paths) { + Node preserved = NodePathAccessor.getNode(source, path); + if (preserved != null) { + NodePathEditor.put(target, path, preserved.clone()); + } + } + } + + private void preserveComputeFields(Node target, Node source, Map fields) { + if (fields == null || fields.isEmpty()) { + return; + } + for (Map.Entry entry : fields.entrySet()) { + NodePathEditor.put(target, "/" + entry.getKey(), entry.getValue().clone()); + } + } + + private void suppressComputeFields(Node source, List paths) { + if (paths == null || paths.isEmpty() || source.getProperties() == null) { + return; + } + Map suppressed = new LinkedHashMap(); + for (String path : paths) { + String key = topLevelKey(path); + if (key != null && source.getProperties().containsKey(key)) { + suppressed.put(key, source.getProperties().remove(key)); + } + } + if (!suppressed.isEmpty()) { + suppressedComputeFields.get().put(source, suppressed); + } + } + + private void restoreComputeFields(Node source, Map fields) { + if (fields == null || fields.isEmpty()) { + return; + } + if (source.getProperties() == null) { + source.properties(new LinkedHashMap()); + } + source.getProperties().putAll(fields); + } + + private String topLevelKey(String path) { + if (path == null || path.length() < 2 || path.charAt(0) != '/') { + return null; + } + String key = path.substring(1); + return key.indexOf('/') >= 0 ? null : key; + } + + private void preserveAuthoredMetadata(Node target, Node source) { + if (source.getName() != null && target.getName() == null) { + target.name(source.getName()); + } + if (source.getDescription() != null && target.getDescription() == null) { + target.description(source.getDescription()); + } + } + + private void stripComputeRuntimeDefaults(Node target, Node source) { + if (!isComputeMerge(target, source)) { + return; + } + stripRuntimeDefault(target, source, "emitEvents"); + stripRuntimeDefault(target, source, "returnResult"); + } + + private boolean isComputeMerge(Node target, Node source) { + if (target == null || source == null || target.getProperties() == null || source.getProperties() == null) { + return false; + } + if (!source.getProperties().containsKey("emitEvents") && !source.getProperties().containsKey("returnResult")) { + return false; + } + return hasTypeBlueId(source, Compute.blueId()) + || hasTypeBlueId(target, Compute.blueId()) + || ("Compute".equals(target.getName()) + && target.getProperties().containsKey("emitEvents") + && target.getProperties().containsKey("returnResult")); + } + + private boolean isComputeNode(Node node) { + return hasTypeBlueId(node, Compute.blueId()) + || hasTypeBlueId(node, ComputeDefinition.blueId()) + || "Coordination/Compute".equals(typeValue(node)) + || "Coordination/Compute Definition".equals(typeValue(node)); + } + + private String typeValue(Node node) { + if (node == null || node.getType() == null || node.getType().getValue() == null) { + return null; + } + return String.valueOf(node.getType().getValue()); + } + + private Node property(Node node, String key) { + return node != null && node.getProperties() != null ? node.getProperties().get(key) : null; + } + + private void stripRuntimeDefault(Node target, Node source, String key) { + Map targetProperties = target.getProperties(); + Map sourceProperties = source.getProperties(); + Node sourceValue = sourceProperties.get(key); + if (sourceValue == null || sourceValue.getValue() == null) { + return; + } + Node targetValue = targetProperties.get(key); + if (targetValue == null || !Boolean.TRUE.equals(targetValue.getValue())) { + return; + } + Node stripped = targetValue.clone(); + stripped.value((Object) null); + targetProperties.put(key, stripped); + } + + private boolean hasTypeBlueId(Node node, String blueId) { + return node != null + && node.getType() != null + && blueId.equals(node.getType().getBlueId()); + } +} diff --git a/src/main/java/blue/coordination/processor/merge/CoordinationMerging.java b/src/main/java/blue/coordination/processor/merge/CoordinationMerging.java new file mode 100644 index 0000000..8ba3d74 --- /dev/null +++ b/src/main/java/blue/coordination/processor/merge/CoordinationMerging.java @@ -0,0 +1,20 @@ +package blue.coordination.processor.merge; + +import blue.language.Blue; +import blue.language.merge.MergingProcessor; + +public final class CoordinationMerging { + private CoordinationMerging() { + } + + public static void install(Blue blue) { + if (blue == null) { + throw new IllegalArgumentException("blue must not be null"); + } + MergingProcessor current = blue.getMergingProcessor(); + if (current instanceof ComputeRuntimeDefaultMergingProcessor) { + return; + } + blue.mergingProcessor(new ComputeRuntimeDefaultMergingProcessor(current)); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java b/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java new file mode 100644 index 0000000..35d0311 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java @@ -0,0 +1,127 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +final class ComputeDefinitionResolver { + private final BexProcessingMetrics metrics; + private final ConcurrentMap cache = new ConcurrentHashMap(); + + ComputeDefinitionResolver() { + this(null); + } + + ComputeDefinitionResolver(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + FrozenNode resolve(FrozenNode stepNode, StepExecutionContext context) { + FrozenNode definition = FrozenNodeUtil.property(stepNode, "definition"); + if (definition == null || FrozenNodeUtil.isEmpty(definition)) { + return null; + } + String text = FrozenNodeUtil.text(definition); + if (text != null && !text.trim().isEmpty()) { + String pointer = resolvePointer(text.trim(), context); + FrozenNode frozen = cachedFrozenAt(pointer, context); + if (frozen == null) { + context.processorContext().throwFatal("Compute definition not found: " + text); + return null; + } + return frozen; + } + return definition; + } + + FrozenNode resolve(Node stepNode, StepExecutionContext context) { + Node definition = NodeUtil.property(stepNode, "definition"); + if (definition == null || NodeUtil.isEmpty(definition)) { + return null; + } + String text = NodeUtil.text(definition); + if (text != null && !text.trim().isEmpty()) { + String pointer = resolvePointer(text.trim(), context); + FrozenNode frozen = cachedFrozenAt(pointer, context); + if (frozen == null) { + context.processorContext().throwFatal("Compute definition not found: " + text); + return null; + } + return frozen; + } + return FrozenNode.fromResolvedNode(definition); + } + + String resolvePointer(String reference, StepExecutionContext context) { + if (reference.startsWith("/")) { + return reference; + } + String parent = parentPointer(currentContractPointer(context)); + if (parent == null || parent.isEmpty()) { + parent = "/"; + } + return appendPointer(parent, reference); + } + + private FrozenNode cachedFrozenAt(String pointer, StepExecutionContext context) { + String key = cacheKey(pointer, context); + FrozenNode cached = cache.get(key); + if (cached != null) { + if (metrics != null) { + metrics.incrementComputeDefinitionResolveHits(); + } + return cached; + } + FrozenNode frozen = context.processorContext().canonicalFrozenAt(pointer); + if (frozen != null) { + cache.putIfAbsent(key, frozen); + } + if (metrics != null) { + metrics.incrementComputeDefinitionResolveMisses(); + } + return frozen; + } + + private String cacheKey(String pointer, StepExecutionContext context) { + FrozenNode contract = context.currentContractFrozenNode(); + String contractId = contract != null && contract.blueId() != null + ? contract.blueId() + : String.valueOf(context.processorContext().scopePath()) + ":" + String.valueOf(context.processorContext().contractKey()); + return contractId + "|" + pointer; + } + + private String currentContractPointer(StepExecutionContext context) { + String key = context.processorContext().contractKey(); + if (key == null || key.trim().isEmpty()) { + return context.processorContext().scopePath(); + } + String scope = context.processorContext().scopePath(); + String contracts = appendPointer(scope == null || scope.trim().isEmpty() ? "/" : scope, "contracts"); + return appendPointer(contracts, key.trim()); + } + + private String parentPointer(String pointer) { + if (pointer == null || pointer.isEmpty() || "/".equals(pointer)) { + return "/"; + } + int last = pointer.lastIndexOf('/'); + if (last <= 0) { + return "/"; + } + return pointer.substring(0, last); + } + + private String appendPointer(String parent, String segment) { + String escaped = escapePointerSegment(segment); + if (parent == null || parent.isEmpty() || "/".equals(parent)) { + return "/" + escaped; + } + return parent + "/" + escaped; + } + + private String escapePointerSegment(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java b/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java new file mode 100644 index 0000000..c72847c --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java @@ -0,0 +1,123 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.language.model.Node; + +import java.util.LinkedHashMap; +import java.util.Map; + +final class ComputeProgramNormalizer { + private final RepositoryTypeAliasPreprocessor typeAliasPreprocessor; + + ComputeProgramNormalizer() { + this(new RepositoryTypeAliasPreprocessor()); + } + + ComputeProgramNormalizer(RepositoryTypeAliasPreprocessor typeAliasPreprocessor) { + this.typeAliasPreprocessor = typeAliasPreprocessor; + } + + Node program(Node stepNode) { + Node program = new Node(); + copyMetadata(program, stepNode); + Map properties = new LinkedHashMap(); + putIfMeaningful(properties, "expr", NodeUtil.property(stepNode, "expr")); + putIfMeaningful(properties, "do", normalizeDo(NodeUtil.property(stepNode, "do"))); + putIfMeaningful(properties, "definition", NodeUtil.property(stepNode, "definition")); + putIfMeaningful(properties, "entry", NodeUtil.property(stepNode, "entry")); + putIfMeaningful(properties, "constants", authoredMap(NodeUtil.property(stepNode, "constants"))); + putIfMeaningful(properties, "functions", normalizeFunctions(NodeUtil.property(stepNode, "functions"))); + putIfMeaningful(properties, "gasLimit", NodeUtil.property(stepNode, "gasLimit")); + putIfMeaningful(properties, "emitEvents", NodeUtil.property(stepNode, "emitEvents")); + putIfMeaningful(properties, "returnResult", NodeUtil.property(stepNode, "returnResult")); + if (!properties.isEmpty()) { + program.properties(properties); + } + return typeAliasPreprocessor.preprocess(program); + } + + Node definition(Node definitionNode) { + Node definition = new Node(); + copyMetadata(definition, definitionNode); + Map properties = new LinkedHashMap(); + putIfMeaningful(properties, "constants", authoredMap(NodeUtil.property(definitionNode, "constants"))); + putIfMeaningful(properties, "functions", normalizeFunctions(NodeUtil.property(definitionNode, "functions"))); + if (!properties.isEmpty()) { + definition.properties(properties); + } + return typeAliasPreprocessor.preprocess(definition); + } + + private Node normalizeFunctions(Node functions) { + if (functions == null || functions.getProperties() == null || functions.getProperties().isEmpty()) { + return null; + } + Map normalized = new LinkedHashMap(); + for (Map.Entry entry : functions.getProperties().entrySet()) { + normalized.put(entry.getKey(), normalizeFunction(entry.getValue())); + } + return new Node().properties(normalized); + } + + private Node normalizeFunction(Node function) { + if (function == null || function.getProperties() == null) { + return function != null ? function.clone() : new Node(); + } + Map properties = new LinkedHashMap(); + putIfMeaningful(properties, "args", authoredMap(NodeUtil.property(function, "args"))); + putIfMeaningful(properties, "expr", NodeUtil.property(function, "expr")); + putIfMeaningful(properties, "do", normalizeDo(NodeUtil.property(function, "do"))); + return new Node().properties(properties); + } + + private Node normalizeDo(Node doNode) { + if (doNode == null || doNode.getItems() == null || doNode.getItems().isEmpty()) { + return null; + } + java.util.List items = new java.util.ArrayList(); + for (Node item : doNode.getItems()) { + items.add(normalizeStatement(item)); + } + return new Node().items(items); + } + + private Node normalizeStatement(Node statement) { + if (NodeUtil.isEmpty(statement)) { + return new Node().properties("$return", new Node()); + } + return statement.clone(); + } + + private Node authoredMap(Node node) { + if (node == null || node.getProperties() == null || node.getProperties().isEmpty()) { + return null; + } + Map properties = new LinkedHashMap(); + for (Map.Entry entry : node.getProperties().entrySet()) { + properties.put(entry.getKey(), entry.getValue().clone()); + } + return new Node().properties(properties); + } + + private void putIfMeaningful(Map properties, String key, Node value) { + if (hasAuthoredContent(value)) { + properties.put(key, value.clone()); + } + } + + private boolean hasAuthoredContent(Node node) { + return node != null + && (node.getValue() != null + || (node.getItems() != null && !node.getItems().isEmpty()) + || (node.getProperties() != null && !node.getProperties().isEmpty())); + } + + private void copyMetadata(Node target, Node source) { + if (source == null) { + return; + } + target.name(source.getName()); + target.description(source.getDescription()); + target.type(source.getType() != null ? source.getType().clone() : null); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java b/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java new file mode 100644 index 0000000..07f7f0c --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java @@ -0,0 +1,260 @@ +package blue.coordination.processor.workflow; + +import blue.bex.result.BexChangeset; +import blue.bex.result.BexExecutionResult; +import blue.bex.result.BexPatchEntry; +import blue.bex.value.BexNodeWriter; +import blue.bex.value.BexValue; +import blue.bex.value.BexValues; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.JsonPatch; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class ComputeResultEmitter { + private final BexProcessingMetrics metrics; + + ComputeResultEmitter() { + this(null); + } + + ComputeResultEmitter(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + int applyChangeset(BexExecutionResult result, StepExecutionContext context) { + List patches = changesetPatches(result, context); + if (patches == null || patches.isEmpty()) { + return 0; + } + applyPatches(patches, context); + return patches.size(); + } + + boolean hasReturnedChangeset(BexExecutionResult result) { + BexValue changeset = result.value() != null ? result.value().get("changeset") : BexValues.undefined(); + return !changeset.isUndefined() && !changeset.isNull(); + } + + int emit(BexExecutionResult result, StepExecutionContext context) { + BexValue events = result.value() != null ? result.value().get("events") : BexValues.undefined(); + if (events.isUndefined() || events.isNull()) { + events = result.events().asValue(); + } + if (events.isUndefined() || events.isNull()) { + return 0; + } + if (!events.isList()) { + context.processorContext().throwFatal("Compute result events must be a list"); + return 0; + } + if (events.size() == 0) { + return 0; + } + int emitted = 0; + for (int i = 0; i < events.size(); i++) { + BexValue event = events.get(String.valueOf(i)); + if (event.isUndefined() || event.isNull()) { + context.processorContext().throwFatal("Compute result events cannot contain undefined/null entries"); + return emitted; + } + if (!event.isObject()) { + context.processorContext().throwFatal("Compute result events must contain object entries"); + return emitted; + } + Node eventNode = BexNodeWriter.toNode(event); + context.processorContext().emitEvent(eventNode); + emitted++; + } + return emitted; + } + + private List changesetPatches(BexExecutionResult result, StepExecutionContext context) { + BexValue changeset = result.value() != null ? result.value().get("changeset") : BexValues.undefined(); + BexChangeset accumulated = result.changeset(); + if (changeset.isUndefined() || changeset.isNull()) { + return patchesFromBexChangeset(accumulated, context); + } + if (!changeset.isList()) { + context.processorContext().throwFatal("Compute result changeset must be a list"); + return null; + } + if (changeset.size() == 0) { + return null; + } + if (isAccumulatedChangesetValue(changeset, accumulated)) { + return patchesFromBexChangeset(accumulated, context); + } + List patches = new ArrayList(changeset.size()); + for (int i = 0; i < changeset.size(); i++) { + BexValue item = changeset.get(String.valueOf(i)); + WorkflowPatchEntry entry = patchEntry(item, i, context); + if (entry == null) { + return null; + } + patches.add(toPatch(entry, context)); + } + return patches; + } + + private List patchesFromBexChangeset(BexChangeset changeset, StepExecutionContext context) { + if (changeset == null || changeset.entries().isEmpty()) { + return null; + } + if (metrics != null) { + metrics.incrementDirectBexChangesetHits(); + } + long conversionStart = System.nanoTime(); + try { + List patches = new ArrayList(changeset.entries().size()); + for (BexPatchEntry entry : changeset.entries()) { + patches.add(toPatch(entry, context)); + if (metrics != null) { + metrics.incrementDirectBexPatchEntryConversions(); + } + } + return patches; + } finally { + if (metrics != null) { + metrics.addUpdatePatchConversionNanos(System.nanoTime() - conversionStart); + } + } + } + + private WorkflowPatchEntry patchEntry(BexValue item, int index, StepExecutionContext context) { + if (item == null || item.isUndefined() || item.isNull() || !item.isObject()) { + context.processorContext().throwFatal("Compute result changeset entry " + index + " must be an object"); + return null; + } + String op = textValue(item.get("op")); + String path = textValue(item.get("path")); + if (!"add".equals(op) && !"replace".equals(op) && !"remove".equals(op)) { + context.processorContext().throwFatal("Invalid patch op in Compute result changeset: " + op); + return null; + } + if (path == null || path.trim().isEmpty()) { + context.processorContext().throwFatal("Compute result changeset entry " + index + " missing path"); + return null; + } + Node nodeValue = null; + if (!"remove".equals(op)) { + BexValue val = item.get("val"); + if (val.isUndefined()) { + context.processorContext().throwFatal("Compute result changeset entry " + index + " missing val"); + return null; + } + long writerStart = System.nanoTime(); + nodeValue = BexNodeWriter.toNode(val); + if (metrics != null) { + metrics.addBexNodeWriterNanos(System.nanoTime() - writerStart); + } + } + return new WorkflowPatchEntry(op, path, nodeValue); + } + + private JsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context) { + String normalizedOp = entry.op().trim().toLowerCase(); + String path = context.processorContext().resolvePointer(entry.path()); + if ("remove".equals(normalizedOp)) { + return JsonPatch.remove(path); + } + Node value = entry.val(); + if (value == null) { + context.processorContext().throwFatal("Compute result patch value is required for operation: " + entry.op()); + return null; + } + if ("add".equals(normalizedOp)) { + return JsonPatch.add(path, value); + } + if ("replace".equals(normalizedOp)) { + return JsonPatch.replace(path, value); + } + context.processorContext().throwFatal("Unsupported Compute result patch operation: " + entry.op()); + return null; + } + + private JsonPatch toPatch(BexPatchEntry entry, StepExecutionContext context) { + String normalizedOp = entry.op().trim().toLowerCase(); + String path = context.processorContext().resolvePointer(entry.authoredPath()); + if ("remove".equals(normalizedOp)) { + return JsonPatch.remove(path); + } + if (entry.val() == null || entry.val().isUndefined()) { + context.processorContext().throwFatal("Compute result patch value is required for operation: " + entry.op()); + return null; + } + long writerStart = System.nanoTime(); + Node value = BexNodeWriter.toNode(entry.val()); + if (metrics != null) { + metrics.addBexNodeWriterNanos(System.nanoTime() - writerStart); + } + if ("add".equals(normalizedOp)) { + return JsonPatch.add(path, value); + } + if ("replace".equals(normalizedOp)) { + return JsonPatch.replace(path, value); + } + context.processorContext().throwFatal("Unsupported Compute result patch operation: " + entry.op()); + return null; + } + + private void applyPatches(List patches, StepExecutionContext context) { + long applyStart = System.nanoTime(); + boolean applied = false; + try { + WorkingDocument.Preview preview = context.advanceWorkingDocument(patches); + context.processorContext().applyPreviewedPatches(patches, preview); + applied = true; + } finally { + if (metrics != null) { + metrics.addUpdatePatchApplyNanos(System.nanoTime() - applyStart); + if (applied) { + metrics.addPatchesApplied(patches.size()); + metrics.incrementUpdateBatchPatchApplications(); + } + } + } + } + + private boolean isAccumulatedChangesetValue(BexValue value, BexChangeset changeset) { + if (value == null || !value.isList() || changeset == null) { + return false; + } + if (value.size() != changeset.entries().size()) { + return false; + } + for (int i = 0; i < value.size(); i++) { + BexValue item = value.get(String.valueOf(i)); + BexPatchEntry entry = changeset.entries().get(i); + if (item == null || !item.isObject()) { + return false; + } + if (!entry.op().equals(textValue(item.get("op")))) { + return false; + } + String path = textValue(item.get("path")); + if (!entry.authoredPath().equals(path) && !entry.absolutePath().equals(path)) { + return false; + } + BexValue val = item.get("val"); + if (entry.val() == null || entry.val().isUndefined()) { + if (val != null && !val.isUndefined() && !val.isNull()) { + return false; + } + } else if (val == null || val.isUndefined() || !Objects.equals(entry.val().toSimple(), val.toSimple())) { + return false; + } + } + return true; + } + + private String textValue(BexValue value) { + if (value == null || value.isUndefined() || value.isNull()) { + return null; + } + return value.asText(); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java new file mode 100644 index 0000000..54118bb --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java @@ -0,0 +1,152 @@ +package blue.coordination.processor.workflow; + +import blue.bex.BexException; +import blue.bex.api.BexEngine; +import blue.bex.api.BexExecutionContext; +import blue.bex.api.BexProgramSource; +import blue.bex.result.BexExecutionResult; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.bex.BexWorkflowContextFactory; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflowStep; + +public final class ComputeStepExecutor implements WorkflowStepExecutor { + private final BexEngine bexEngine; + private final long defaultGasLimit; + private final ComputeDefinitionResolver definitionResolver; + private final BexWorkflowContextFactory contextFactory; + private final ComputeResultEmitter resultEmitter; + private final ComputeProgramNormalizer normalizer; + private final BexProcessingMetrics metrics; + + public ComputeStepExecutor() { + this(BexEngine.builder().build(), 100_000L); + } + + public ComputeStepExecutor(BexEngine bexEngine, long defaultGasLimit) { + this(bexEngine, + defaultGasLimit, + new ComputeDefinitionResolver(), + new BexWorkflowContextFactory(), + new ComputeResultEmitter(), + null); + } + + ComputeStepExecutor(BexEngine bexEngine, + long defaultGasLimit, + ComputeDefinitionResolver definitionResolver, + BexWorkflowContextFactory contextFactory, + ComputeResultEmitter resultEmitter, + BexProcessingMetrics metrics) { + if (bexEngine == null) { + throw new IllegalArgumentException("bexEngine must not be null"); + } + if (defaultGasLimit <= 0L) { + throw new IllegalArgumentException("defaultGasLimit must be positive"); + } + if (definitionResolver == null) { + throw new IllegalArgumentException("definitionResolver must not be null"); + } + if (contextFactory == null) { + throw new IllegalArgumentException("contextFactory must not be null"); + } + if (resultEmitter == null) { + throw new IllegalArgumentException("resultEmitter must not be null"); + } + this.bexEngine = bexEngine; + this.defaultGasLimit = defaultGasLimit; + this.definitionResolver = definitionResolver; + this.contextFactory = contextFactory; + this.resultEmitter = resultEmitter; + this.normalizer = new ComputeProgramNormalizer(); + this.metrics = metrics; + } + + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof Compute; + } + + @Override + public WorkflowStepResult execute(Compute step, StepExecutionContext context) { + long stepStart = System.nanoTime(); + try { + if (metrics != null) { + metrics.incrementComputeStepsExecuted(); + } + Node rawStepNode = context.stepNodeRef(); + if (rawStepNode == null) { + context.processorContext().throwFatal("Compute step must have a raw step node"); + return WorkflowStepResult.none(); + } + FrozenNode programNode = FrozenNode.fromResolvedNode(normalizer.program(rawStepNode)); + long resolveStart = System.nanoTime(); + FrozenNode definitionNode = definitionResolver.resolve(programNode, context); + if (definitionNode != null) { + definitionNode = FrozenNode.fromResolvedNode(normalizer.definition(definitionNode.toNode())); + } + if (metrics != null) { + metrics.addComputeDefinitionResolveNanos(System.nanoTime() - resolveStart); + } + String entry = FrozenNodeUtil.textProperty(programNode, "entry"); + long sourceStart = System.nanoTime(); + BexProgramSource source = definitionNode != null + ? BexProgramSource.withDefinition(programNode, definitionNode, entry) + : BexProgramSource.inline(programNode); + if (metrics != null) { + metrics.addComputeProgramSourceBuildNanos(System.nanoTime() - sourceStart); + } + long contextStart = System.nanoTime(); + BexExecutionContext bexContext = contextFactory.create(context, computeGasLimit(programNode)); + if (metrics != null) { + metrics.addComputeContextBuildNanos(System.nanoTime() - contextStart); + } + long executeStart = System.nanoTime(); + BexExecutionResult result = bexEngine.compileAndExecute(source, bexContext); + if (metrics != null) { + metrics.addComputeCompileExecuteNanos(System.nanoTime() - executeStart); + metrics.addBexMetrics(result.metrics()); + } + if (result.gasUsed() > 0L) { + context.processorContext().consumeGas(result.gasUsed()); + } + int appliedPatches = resultEmitter.applyChangeset(result, context); + if (FrozenNodeUtil.booleanProperty(programNode, "emitEvents", true)) { + int emitted = resultEmitter.emit(result, context); + if (metrics != null) { + for (int i = 0; i < emitted; i++) { + metrics.incrementEventsEmitted(); + } + } + } + if (!FrozenNodeUtil.booleanProperty(programNode, "returnResult", true)) { + return WorkflowStepResult.none(); + } + return WorkflowStepResult.value(result, appliedPatches > 0 || resultEmitter.hasReturnedChangeset(result)); + } catch (BexException ex) { + context.processorContext().throwFatal("Compute failed: " + ex.getMessage()); + return WorkflowStepResult.none(); + } catch (RuntimeException ex) { + context.processorContext().throwFatal("Compute failed: " + ex.getMessage()); + return WorkflowStepResult.none(); + } finally { + if (metrics != null) { + metrics.addComputeStepNanos(System.nanoTime() - stepStart); + } + } + } + + private long computeGasLimit(FrozenNode stepNode) { + Long parsed = FrozenNodeUtil.integer(FrozenNodeUtil.property(stepNode, "gasLimit")); + if (parsed == null) { + return defaultGasLimit; + } + if (parsed.longValue() <= 0L) { + throw new BexException("Compute gasLimit must be positive"); + } + return parsed.longValue(); + } + +} diff --git a/src/main/java/blue/coordination/processor/workflow/FrozenNodeUtil.java b/src/main/java/blue/coordination/processor/workflow/FrozenNodeUtil.java new file mode 100644 index 0000000..c26f818 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/FrozenNodeUtil.java @@ -0,0 +1,74 @@ +package blue.coordination.processor.workflow; + +import blue.language.snapshot.FrozenNode; + +import java.math.BigInteger; + +final class FrozenNodeUtil { + private FrozenNodeUtil() { + } + + static FrozenNode property(FrozenNode node, String key) { + return node != null && node.getProperties() != null ? node.getProperties().get(key) : null; + } + + static boolean isEmpty(FrozenNode node) { + return node == null || (node.getValue() == null + && (node.getItems() == null || node.getItems().isEmpty()) + && (node.getProperties() == null || node.getProperties().isEmpty())); + } + + static Object rawScalar(FrozenNode node) { + if (node == null) { + return null; + } + if (node.getValue() != null) { + return node.getValue(); + } + if (node.getProperties() != null && node.getProperties().containsKey("value")) { + return rawScalar(node.getProperties().get("value")); + } + return null; + } + + static String text(FrozenNode node) { + Object raw = rawScalar(node); + return raw != null ? String.valueOf(raw) : null; + } + + static String textProperty(FrozenNode node, String key) { + return text(property(node, key)); + } + + static boolean booleanProperty(FrozenNode node, String key, boolean defaultValue) { + Object raw = rawScalar(property(node, key)); + if (raw == null) { + return defaultValue; + } + if (raw instanceof Boolean) { + return ((Boolean) raw).booleanValue(); + } + if (raw instanceof String) { + return Boolean.parseBoolean((String) raw); + } + return defaultValue; + } + + static Long integer(FrozenNode node) { + Object raw = rawScalar(node); + if (raw instanceof BigInteger) { + return Long.valueOf(((BigInteger) raw).longValue()); + } + if (raw instanceof Number) { + return Long.valueOf(((Number) raw).longValue()); + } + if (raw instanceof String) { + try { + return Long.valueOf((String) raw); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/NodeUtil.java b/src/main/java/blue/coordination/processor/workflow/NodeUtil.java new file mode 100644 index 0000000..1b1a9e2 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/NodeUtil.java @@ -0,0 +1,68 @@ +package blue.coordination.processor.workflow; + +import blue.language.model.Node; + +import java.util.Map; + +final class NodeUtil { + private NodeUtil() { + } + + static Node property(Node node, String key) { + if (node == null || node.getProperties() == null) { + return null; + } + return node.getProperties().get(key); + } + + static boolean isEmpty(Node node) { + return node == null + || (node.getValue() == null + && empty(node.getItems()) + && empty(node.getProperties())); + } + + static Object rawScalar(Node node) { + if (node == null) { + return null; + } + if (node.getValue() != null) { + return node.getValue(); + } + if (node.getProperties() != null && node.getProperties().containsKey("value")) { + return rawScalar(node.getProperties().get("value")); + } + return null; + } + + static String text(Node node) { + Object raw = rawScalar(node); + return raw != null ? String.valueOf(raw) : null; + } + + static String textProperty(Node node, String key) { + return text(property(node, key)); + } + + static boolean booleanProperty(Node node, String key, boolean defaultValue) { + Object raw = rawScalar(property(node, key)); + if (raw == null) { + return defaultValue; + } + if (raw instanceof Boolean) { + return ((Boolean) raw).booleanValue(); + } + if (raw instanceof String) { + return Boolean.parseBoolean((String) raw); + } + return defaultValue; + } + + private static boolean empty(Map map) { + return map == null || map.isEmpty(); + } + + private static boolean empty(Iterable items) { + return items == null || !items.iterator().hasNext(); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java new file mode 100644 index 0000000..3db188d --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java @@ -0,0 +1,237 @@ +package blue.coordination.processor.workflow; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.bex.BexWorkflowContextFactory; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.processor.WorkingDocument; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflow; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TriggerEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class SequentialWorkflowRunner { + private final List> executors; + private final BexProcessingMetrics metrics; + + public SequentialWorkflowRunner() { + this(defaultExecutors()); + } + + public SequentialWorkflowRunner(List> executors) { + this(executors, null); + } + + private SequentialWorkflowRunner(List> executors, + BexProcessingMetrics metrics) { + if (executors == null) { + throw new IllegalArgumentException("executors must not be null"); + } + this.executors = Collections.unmodifiableList(new ArrayList>(executors)); + this.metrics = metrics; + } + + public void execute(SequentialWorkflow workflow, ProcessorExecutionContext context) { + long start = System.nanoTime(); + try { + if (workflow.getSteps() == null) { + return; + } + Map stepResults = new LinkedHashMap(); + Set handledChangesetSteps = new LinkedHashSet(); + FrozenNode contractNode = rawContractNode(context); + List stepNodes = stepNodes(contractNode); + List steps = workflow.getSteps(); + WorkingDocument workingDocument = rootWorkingDocument(context); + for (int i = 0; i < steps.size(); i++) { + SequentialWorkflowStep step = steps.get(i); + FrozenNode stepNode = i < stepNodes.size() ? stepNodes.get(i) : null; + if (metrics != null) { + metrics.incrementWorkflowStepsExecuted(); + } + WorkflowStepResult result = executeStep(workflow, + step, + stepNode, + contractNode, + i, + stepResults, + handledChangesetSteps, + context, + workingDocument); + if (result != null && result.hasValue()) { + String key = stepKey(stepNode, i); + stepResults.put(key, result.value()); + if (result.changesetHandled()) { + handledChangesetSteps.add(key); + } + } + } + } finally { + if (metrics != null) { + metrics.addWorkflowRunnerNanos(System.nanoTime() - start); + } + } + } + + private WorkflowStepResult executeStep(SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepNode, + FrozenNode contractNode, + int stepIndex, + Map stepResults, + Set handledChangesetSteps, + ProcessorExecutionContext context, + WorkingDocument workingDocument) { + if (step == null) { + context.throwFatal("Unsupported null sequential workflow step"); + return WorkflowStepResult.none(); + } + for (WorkflowStepExecutor executor : executors) { + if (executor.supports(step)) { + StepExecutionContext stepContext = new StepExecutionContext(context, + workflow, + step, + stepNode, + contractNode, + stepIndex, + stepResults, + handledChangesetSteps, + workingDocument); + return executeSupported(executor, step, stepContext); + } + } + context.throwFatal("Unsupported sequential workflow step: " + stepName(step)); + return WorkflowStepResult.none(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private WorkflowStepResult executeSupported(WorkflowStepExecutor executor, + SequentialWorkflowStep step, + StepExecutionContext context) { + return executor.execute(step, context); + } + + private String stepName(SequentialWorkflowStep step) { + if (step instanceof TriggerEvent) { + return "Coordination/Trigger Event"; + } + if (step instanceof Compute) { + return "Coordination/Compute"; + } + return step.getClass().getName(); + } + + private static List> defaultExecutors() { + return executorsFor(BexEngine.builder().build(), 100_000L); + } + + public static SequentialWorkflowRunner withBexEngine(BexEngine bexEngine) { + if (bexEngine == null) { + throw new IllegalArgumentException("bexEngine must not be null"); + } + return new SequentialWorkflowRunner(executorsFor(bexEngine, 100_000L)); + } + + public static SequentialWorkflowRunner withBexEngine(BexEngine bexEngine, + long computeGasLimit) { + return withBexEngine(bexEngine, computeGasLimit, null); + } + + public static SequentialWorkflowRunner withBexEngine(BexEngine bexEngine, + long computeGasLimit, + BexProcessingMetrics metrics) { + if (bexEngine == null) { + throw new IllegalArgumentException("bexEngine must not be null"); + } + if (computeGasLimit <= 0L) { + throw new IllegalArgumentException("computeGasLimit must be positive"); + } + return new SequentialWorkflowRunner(executorsFor(bexEngine, computeGasLimit, metrics), metrics); + } + + private static List> executorsFor(BexEngine bexEngine, + long computeGasLimit) { + return executorsFor(bexEngine, computeGasLimit, null); + } + + private static List> executorsFor(BexEngine bexEngine, + long computeGasLimit, + BexProcessingMetrics metrics) { + BexWorkflowContextFactory bexContextFactory = new BexWorkflowContextFactory(metrics); + return Arrays.>asList( + new TriggerEventStepExecutor(metrics), + new ComputeStepExecutor(bexEngine, + computeGasLimit, + new ComputeDefinitionResolver(metrics), + bexContextFactory, + new ComputeResultEmitter(metrics), + metrics), + new UpdateDocumentStepExecutor(metrics)); + } + + private List stepNodes(FrozenNode contractNode) { + if (contractNode == null || contractNode.getProperties() == null) { + return Collections.emptyList(); + } + FrozenNode steps = contractNode.getProperties().get("steps"); + if (steps == null || steps.getItems() == null) { + return Collections.emptyList(); + } + return steps.getItems(); + } + + private String stepKey(FrozenNode stepNode, int index) { + if (stepNode != null && stepNode.getName() != null && !stepNode.getName().trim().isEmpty()) { + return stepNode.getName().trim(); + } + return "Step" + (index + 1); + } + + private FrozenNode rawContractNode(ProcessorExecutionContext context) { + String pointer = contractPointer(context); + if (pointer == null) { + return context.frozenContractNode(); + } + FrozenNode frozen = context.canonicalFrozenAt(pointer); + return frozen != null ? frozen : context.frozenContractNode(); + } + + private WorkingDocument rootWorkingDocument(ProcessorExecutionContext context) { + WorkingDocument workingDocument = context.newWorkingDocument(); + if (metrics != null) { + if (workingDocument.usedMaterializedFallback()) { + metrics.incrementWorkflowDocumentViewsFromDocument(); + } else { + metrics.incrementWorkflowDocumentViewsFromFrozen(); + } + } + return workingDocument; + } + + private String contractPointer(ProcessorExecutionContext context) { + String key = context.contractKey(); + if (key == null || key.trim().isEmpty()) { + return null; + } + String scope = context.scopePath(); + String contracts = appendPointer(scope == null || scope.trim().isEmpty() ? "/" : scope, "contracts"); + return appendPointer(contracts, key.trim()); + } + + private String appendPointer(String parent, String segment) { + String escaped = segment.replace("~", "~0").replace("/", "~1"); + if (parent == null || parent.isEmpty() || "/".equals(parent)) { + return "/" + escaped; + } + return parent + "/" + escaped; + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java b/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java new file mode 100644 index 0000000..c9c9989 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java @@ -0,0 +1,49 @@ +package blue.coordination.processor.workflow; + +import blue.language.snapshot.FrozenNode; + +import java.util.Map; + +final class StaticPayloadValidator { + private StaticPayloadValidator() { + } + + static boolean rejectBexOperators(FrozenNode node, StepExecutionContext context, String fieldName) { + String path = firstBexOperatorPath(node, ""); + if (path == null) { + return false; + } + context.processorContext().throwFatal(fieldName + " must be static; BEX operator object is not allowed at " + path); + return true; + } + + private static String firstBexOperatorPath(FrozenNode node, String path) { + if (node == null) { + return null; + } + Map properties = node.getProperties(); + if (properties != null) { + if (properties.size() == 1) { + String key = properties.keySet().iterator().next(); + if (key != null && key.startsWith("$")) { + return path.isEmpty() ? "/" : path; + } + } + for (Map.Entry entry : properties.entrySet()) { + String found = firstBexOperatorPath(entry.getValue(), path + "/" + entry.getKey()); + if (found != null) { + return found; + } + } + } + if (node.getItems() != null) { + for (int i = 0; i < node.getItems().size(); i++) { + String found = firstBexOperatorPath(node.getItems().get(i), path + "/" + i); + if (found != null) { + return found; + } + } + } + return null; + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java b/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java new file mode 100644 index 0000000..ec036eb --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java @@ -0,0 +1,248 @@ +package blue.coordination.processor.workflow; + +import blue.language.model.Node; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.SequentialWorkflow; +import blue.repo.coordination.SequentialWorkflowStep; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class StepExecutionContext { + private final ProcessorExecutionContext processorContext; + private final SequentialWorkflow workflow; + private final SequentialWorkflowStep step; + private Node stepNodeRef; + private Node currentContractNodeRef; + private final FrozenNode stepFrozenNode; + private final FrozenNode currentContractFrozenNode; + private final int stepIndex; + private final Map stepResults; + private final Set handledChangesetSteps; + private final Node eventRef; + private WorkingDocument workingDocument; + + public StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + Node stepNode, + Node currentContractNode, + int stepIndex, + Map stepResults) { + this(processorContext, + workflow, + step, + stepNode, + currentContractNode, + null, + null, + stepIndex, + stepResults, + null, + null); + } + + public StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + Map stepResults) { + this(processorContext, + workflow, + step, + null, + null, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + stepResults, + null, + null); + } + + StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + Map stepResults, + WorkingDocument workingDocument) { + this(processorContext, + workflow, + step, + null, + null, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + stepResults, + null, + workingDocument); + } + + StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + Map stepResults, + Set handledChangesetSteps, + WorkingDocument workingDocument) { + this(processorContext, + workflow, + step, + null, + null, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + stepResults, + handledChangesetSteps, + workingDocument); + } + + private StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + Node stepNode, + Node currentContractNode, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + Map stepResults, + Set handledChangesetSteps, + WorkingDocument workingDocument) { + if (processorContext == null) { + throw new IllegalArgumentException("processorContext must not be null"); + } + if (workflow == null) { + throw new IllegalArgumentException("workflow must not be null"); + } + this.processorContext = processorContext; + this.workflow = workflow; + this.step = step; + this.stepNodeRef = stepNode; + this.currentContractNodeRef = currentContractNode; + this.stepFrozenNode = stepFrozenNode; + this.currentContractFrozenNode = currentContractFrozenNode; + this.stepIndex = stepIndex; + this.stepResults = Collections.unmodifiableMap(new LinkedHashMap( + stepResults != null ? stepResults : Collections.emptyMap())); + this.handledChangesetSteps = Collections.unmodifiableSet(new LinkedHashSet( + handledChangesetSteps != null ? handledChangesetSteps : Collections.emptySet())); + this.eventRef = processorContext.event(); + this.workingDocument = workingDocument; + } + + public ProcessorExecutionContext processorContext() { + return processorContext; + } + + public SequentialWorkflow workflow() { + return workflow; + } + + public SequentialWorkflowStep step() { + return step; + } + + public Node stepNodeRef() { + if (stepNodeRef == null && stepFrozenNode != null) { + stepNodeRef = stepFrozenNode.toNode(); + } + return stepNodeRef; + } + + public Node currentContractNodeRef() { + if (currentContractNodeRef == null && currentContractFrozenNode != null) { + currentContractNodeRef = currentContractFrozenNode.toNode(); + } + return currentContractNodeRef; + } + + public FrozenNode stepFrozenNode() { + return stepFrozenNode; + } + + public FrozenNode currentContractFrozenNode() { + return currentContractFrozenNode; + } + + public Node eventRef() { + return eventRef; + } + + public Node stepNode() { + Node ref = stepNodeRef(); + return ref != null ? ref.clone() : null; + } + + public Node currentContractNode() { + Node ref = currentContractNodeRef(); + return ref != null ? ref.clone() : null; + } + + public int stepIndex() { + return stepIndex; + } + + public Map stepResults() { + return stepResults; + } + + boolean wasChangesetHandled(String stepKey) { + return stepKey != null && handledChangesetSteps.contains(stepKey); + } + + public Node event() { + return eventRef != null ? eventRef.clone() : null; + } + + public Node documentView() { + FrozenNode scoped = workingDocument().canonicalAt(processorContext.resolvePointer("/")); + return scoped != null ? scoped.toNode() : null; + } + + public WorkingDocument workingDocument() { + if (workingDocument == null) { + workingDocument = processorContext.newWorkingDocument(); + } + return workingDocument; + } + + public FrozenNode workingCanonicalAt(String absolutePointer) { + if (absolutePointer == null || absolutePointer.isEmpty()) { + return null; + } + return workingDocument().canonicalAt(absolutePointer); + } + + public FrozenNode workingResolvedAt(String absolutePointer) { + if (absolutePointer == null || absolutePointer.isEmpty()) { + return null; + } + return workingDocument().resolvedAt(absolutePointer); + } + + WorkingDocument.Preview advanceWorkingDocument(List patches) { + if (patches == null || patches.isEmpty()) { + return null; + } + try { + return workingDocument().previewAndApplyPatches(patches); + } catch (RuntimeException ex) { + processorContext.throwFatal("Working document preview failed: " + ex.getMessage()); + return null; + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/TriggerEventStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/TriggerEventStepExecutor.java new file mode 100644 index 0000000..18cc471 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/TriggerEventStepExecutor.java @@ -0,0 +1,112 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TriggerEvent; + +import java.util.Map; + +public final class TriggerEventStepExecutor implements WorkflowStepExecutor { + private final BexProcessingMetrics metrics; + + public TriggerEventStepExecutor() { + this(null); + } + + public TriggerEventStepExecutor(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + long stepStart = System.nanoTime(); + try { + if (step == null) { + context.processorContext().throwFatal("Trigger Event step payload is invalid"); + return WorkflowStepResult.none(); + } + if (metrics != null) { + metrics.incrementTriggerEventStepsExecuted(); + } + FrozenNode rawEvent = FrozenNodeUtil.property(context.stepFrozenNode(), "event"); + if (!hasDeclaredEvent(context.stepFrozenNode())) { + context.processorContext().throwFatal("Trigger Event step must declare event payload"); + return WorkflowStepResult.none(); + } + if (StaticPayloadValidator.rejectBexOperators(rawEvent, + context, + "Trigger Event event")) { + return WorkflowStepResult.none(); + } + Node event = step.getEvent(); + if (isEmpty(event)) { + context.processorContext().throwFatal("Trigger Event step must declare event payload"); + return WorkflowStepResult.none(); + } + long emitStart = System.nanoTime(); + context.processorContext().emitEvent(event.clone()); + if (metrics != null) { + metrics.addTriggerEmitEventNanos(System.nanoTime() - emitStart); + } + return WorkflowStepResult.none(); + } finally { + if (metrics != null) { + metrics.addTriggerStepNanos(System.nanoTime() - stepStart); + } + } + } + + private static boolean hasDeclaredEvent(Node stepNode) { + if (stepNode == null) { + return true; + } + if (stepNode.getProperties() == null || !stepNode.getProperties().containsKey("event")) { + return false; + } + return !isEmpty(stepNode.getProperties().get("event")); + } + + private static boolean hasDeclaredEvent(FrozenNode stepNode) { + if (stepNode == null) { + return true; + } + if (stepNode.getProperties() == null || !stepNode.getProperties().containsKey("event")) { + return false; + } + return !FrozenNodeUtil.isEmpty(stepNode.getProperties().get("event")); + } + + private static boolean isEmpty(Node node) { + if (node == null) { + return true; + } + return node.getType() == null + && node.getItemType() == null + && node.getKeyType() == null + && node.getValueType() == null + && node.getValue() == null + && empty(node.getItems()) + && empty(node.getProperties()) + && node.getBlueId() == null + && node.getSchema() == null + && node.getMergePolicy() == null + && node.getPreviousBlueId() == null + && node.getPosition() == null + && node.getBlue() == null; + } + + private static boolean empty(Map map) { + return map == null || map.isEmpty(); + } + + private static boolean empty(Iterable items) { + return items == null || !items.iterator().hasNext(); + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java new file mode 100644 index 0000000..58093eb --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java @@ -0,0 +1,194 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.UpdateDocument; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +public final class UpdateDocumentStepExecutor implements WorkflowStepExecutor { + private final BexProcessingMetrics metrics; + + public UpdateDocumentStepExecutor() { + this(null); + } + + public UpdateDocumentStepExecutor(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof UpdateDocument; + } + + @Override + public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { + long stepStart = System.nanoTime(); + try { + if (metrics != null) { + metrics.incrementUpdateDocumentStepsExecuted(); + } + FrozenNode rawFrozenChangeset = FrozenNodeUtil.property(context.stepFrozenNode(), "changeset"); + if (StaticPayloadValidator.rejectBexOperators(rawFrozenChangeset, + context, + "Update Document changeset")) { + return WorkflowStepResult.none(); + } + if (rawFrozenChangeset != null + && rawFrozenChangeset.getItems() == null + && step.getChangeset() == null) { + context.processorContext().throwFatal("Update Document changeset must be a static patch list"); + return WorkflowStepResult.none(); + } + List changeset = literalChangeset(step, context); + if (changeset.isEmpty()) { + return WorkflowStepResult.none(); + } + long conversionStart = System.nanoTime(); + List patches = new ArrayList(changeset.size()); + for (WorkflowPatchEntry entry : changeset) { + patches.add(toPatch(entry, context)); + } + if (metrics != null) { + metrics.addUpdatePatchConversionNanos(System.nanoTime() - conversionStart); + } + applyPatches(patches, context); + return WorkflowStepResult.none(); + } finally { + if (metrics != null) { + metrics.addUpdateStepNanos(System.nanoTime() - stepStart); + } + } + } + + private List literalChangeset(UpdateDocument step, StepExecutionContext context) { + if (step == null || step.getChangeset() == null) { + return java.util.Collections.emptyList(); + } + List rawChangeset = step.getChangeset(); + List entries = new ArrayList(rawChangeset.size()); + for (int i = 0; i < rawChangeset.size(); i++) { + entries.add(literalPatchEntry(rawChangeset.get(i), i, context)); + } + return entries; + } + + private WorkflowPatchEntry literalPatchEntry(Object item, int index, StepExecutionContext context) { + if (item == null) { + return null; + } + if (item instanceof WorkflowPatchEntry) { + return (WorkflowPatchEntry) item; + } + if (item instanceof Node) { + return literalPatchEntry((Node) item, index, context); + } + try { + String op = (String) invokeNoArg(item, "getOp"); + String path = (String) invokeNoArg(item, "getPath"); + Node val = (Node) invokeNoArg(item, "getVal"); + return new WorkflowPatchEntry(op, path, val); + } catch (ReflectiveOperationException ex) { + context.processorContext().throwFatal("Update Document changeset entry " + index + + " cannot be read as a patch entry: " + ex.getMessage()); + return null; + } catch (ClassCastException ex) { + context.processorContext().throwFatal("Update Document changeset entry " + index + + " has invalid patch entry field types"); + return null; + } + } + + private WorkflowPatchEntry literalPatchEntry(Node item, int index, StepExecutionContext context) { + if (item.getProperties() == null) { + context.processorContext().throwFatal("Update Document changeset entry " + index + + " must be a static patch object"); + return null; + } + String op = stringProperty(item, "op", index, context); + String path = stringProperty(item, "path", index, context); + Node val = item.getProperties().get("val"); + return new WorkflowPatchEntry(op, path, val != null ? val.clone() : null); + } + + private String stringProperty(Node item, String key, int index, StepExecutionContext context) { + Node property = item.getProperties().get(key); + Object value = property != null ? property.getValue() : null; + if (value == null) { + return null; + } + if (!(value instanceof String)) { + context.processorContext().throwFatal("Update Document changeset entry " + index + + " field '" + key + "' must be text"); + return null; + } + return (String) value; + } + + private Object invokeNoArg(Object target, String methodName) throws ReflectiveOperationException { + Method method = target.getClass().getMethod(methodName); + return method.invoke(target); + } + + private JsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context) { + if (entry == null) { + context.processorContext().throwFatal("Update Document changeset contains a null patch entry"); + return null; + } + String op = entry.op(); + String path = entry.path(); + if (op == null || op.trim().isEmpty()) { + context.processorContext().throwFatal("Update Document patch operation is required"); + return null; + } + if (path == null || path.trim().isEmpty()) { + context.processorContext().throwFatal("Update Document patch path is required"); + return null; + } + String absolutePath = context.processorContext().resolvePointer(path); + String normalizedOp = op.trim().toLowerCase(); + if ("remove".equals(normalizedOp)) { + return JsonPatch.remove(absolutePath); + } + Node value = entry.val(); + if (value == null) { + context.processorContext().throwFatal("Update Document patch value is required for operation: " + op); + return null; + } + if ("add".equals(normalizedOp)) { + return JsonPatch.add(absolutePath, value); + } + if ("replace".equals(normalizedOp)) { + return JsonPatch.replace(absolutePath, value); + } + context.processorContext().throwFatal("Unsupported Update Document patch operation: " + op); + return null; + } + + private void applyPatches(List patches, StepExecutionContext context) { + if (patches == null || patches.isEmpty()) { + return; + } + long applyStart = System.nanoTime(); + boolean applied = false; + try { + WorkingDocument.Preview preview = context.advanceWorkingDocument(patches); + context.processorContext().applyPreviewedPatches(patches, preview); + applied = true; + } finally { + if (metrics != null) { + metrics.addUpdatePatchApplyNanos(System.nanoTime() - applyStart); + if (applied) { + metrics.addPatchesApplied(patches.size()); + metrics.incrementUpdateBatchPatchApplications(); + } + } + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java b/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java new file mode 100644 index 0000000..f4f4e22 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java @@ -0,0 +1,27 @@ +package blue.coordination.processor.workflow; + +import blue.language.model.Node; + +final class WorkflowPatchEntry { + private final String op; + private final String path; + private final Node val; + + WorkflowPatchEntry(String op, String path, Node val) { + this.op = op; + this.path = path; + this.val = val; + } + + String op() { + return op; + } + + String path() { + return path; + } + + Node val() { + return val; + } +} diff --git a/src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/WorkflowStepExecutor.java similarity index 63% rename from src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepExecutor.java rename to src/main/java/blue/coordination/processor/workflow/WorkflowStepExecutor.java index 009cc7c..576c559 100644 --- a/src/main/java/blue/contract/processor/conversation/workflow/WorkflowStepExecutor.java +++ b/src/main/java/blue/coordination/processor/workflow/WorkflowStepExecutor.java @@ -1,6 +1,6 @@ -package blue.contract.processor.conversation.workflow; +package blue.coordination.processor.workflow; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; +import blue.repo.coordination.SequentialWorkflowStep; public interface WorkflowStepExecutor { boolean supports(SequentialWorkflowStep step); diff --git a/src/main/java/blue/coordination/processor/workflow/WorkflowStepResult.java b/src/main/java/blue/coordination/processor/workflow/WorkflowStepResult.java new file mode 100644 index 0000000..b4a56b0 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/WorkflowStepResult.java @@ -0,0 +1,39 @@ +package blue.coordination.processor.workflow; + +public final class WorkflowStepResult { + private static final WorkflowStepResult NONE = new WorkflowStepResult(false, null, false); + + private final boolean hasValue; + private final Object value; + private final boolean changesetHandled; + + private WorkflowStepResult(boolean hasValue, Object value, boolean changesetHandled) { + this.hasValue = hasValue; + this.value = value; + this.changesetHandled = changesetHandled; + } + + public static WorkflowStepResult none() { + return NONE; + } + + public static WorkflowStepResult value(Object value) { + return value(value, false); + } + + public static WorkflowStepResult value(Object value, boolean changesetHandled) { + return new WorkflowStepResult(true, value, changesetHandled); + } + + public boolean hasValue() { + return hasValue; + } + + public Object value() { + return value; + } + + public boolean changesetHandled() { + return changesetHandled; + } +} diff --git a/src/main/resources/blue/contract/processor/quickjs/evaluate.mjs b/src/main/resources/blue/contract/processor/quickjs/evaluate.mjs deleted file mode 100644 index 632f7b7..0000000 --- a/src/main/resources/blue/contract/processor/quickjs/evaluate.mjs +++ /dev/null @@ -1,216 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import path from 'node:path'; -import readline from 'node:readline'; - -const blueQuickJsRoot = process.argv[2]; -const BLUE_METADATA_KEYS = new Set([ - 'name', - 'description', - 'type', - 'itemType', - 'keyType', - 'valueType', - 'value', - 'items', - 'blue', - 'blueId', - 'schema', - 'mergePolicy', - '$previous', - '$pos', -]); - -function writeResponse(response) { - process.stdout.write(`${JSON.stringify(response)}\n`); -} - -function fail(message) { - writeResponse({ ok: false, message }); -} - -try { - if (!blueQuickJsRoot) { - throw new Error('blue-quickjs root argument is required'); - } - - const runtimePath = path.join( - blueQuickJsRoot, - 'libs/quickjs-runtime/dist/index.js', - ); - const manifestPath = path.join( - blueQuickJsRoot, - 'libs/abi-manifest/dist/index.js', - ); - const runtime = await import(pathToFileURL(runtimePath).href); - const manifest = await import(pathToFileURL(manifestPath).href); - - const input = readline.createInterface({ - input: process.stdin, - crlfDelay: Infinity, - }); - - for await (const line of input) { - if (!line.trim()) { - continue; - } - try { - const request = JSON.parse(line); - writeResponse(await evaluateRequest(runtime, manifest, request)); - } catch (error) { - fail(error instanceof Error ? error.message : String(error)); - } - } -} catch (error) { - fail(error instanceof Error ? error.message : String(error)); -} - -async function evaluateRequest(runtime, manifest, request) { - const bindings = request.bindings ?? {}; - const code = sourceFor(request); - const result = await runtime.evaluate({ - program: { - code, - abiId: 'Host.v1', - abiVersion: 1, - abiManifestHash: manifest.HOST_V1_HASH, - }, - input: { - event: bindings.event ?? null, - eventCanonical: bindings.eventCanonical ?? null, - steps: bindings.steps ?? [], - currentContract: bindings.currentContract ?? null, - currentContractCanonical: bindings.currentContractCanonical ?? null, - }, - gasLimit: BigInt(String(request.wasmGasLimit)), - manifest: manifest.HOST_V1_MANIFEST, - handlers: { - document: { - get: (pointer) => - documentResult(bindings.document, pointer, false, bindings.documentMetadata), - getCanonical: (pointer) => - documentResult(bindings.documentCanonical, pointer, true), - }, - emit: () => ({ - err: { - code: 'LIMIT_EXCEEDED', - details: 'emit is not available during expression/code evaluation', - }, - units: 1, - }), - }, - }); - - if (!result.ok) { - return { - ok: false, - type: result.type, - message: result.message, - wasmGasUsed: result.gasUsed?.toString?.() ?? '0', - }; - } - return { - ok: true, - value: result.value, - wasmGasUsed: result.gasUsed.toString(), - wasmGasRemaining: result.gasRemaining.toString(), - }; -} - -function sourceFor(request) { -const prelude = ` -const __blueDocument = globalThis.document; -const document = Object.assign( - (pointer = '/') => __blueDocument(pointer), - { canonical: (pointer = '/') => __blueDocument.canonical(pointer) }, -); -`; - if (request.mode === 'expression') { - return `(() => {\n${prelude}\nreturn (${request.code});\n})()`; - } - if (request.mode === 'block') { - return `(() => {\n${prelude}\n${request.code}\n})()`; - } - return request.code; -} - -function documentResult(root, pointer, canonical = false, metadata = null) { - const normalized = normalizePointer(pointer); - if (!canonical && metadata && Object.prototype.hasOwnProperty.call(metadata, normalized)) { - return { ok: metadata[normalized] ?? null, units: 1 }; - } - const resolved = getPointer(root, normalized); - if (!resolved.found) { - return { ok: null, units: 1 }; - } - return { ok: canonical ? resolved.value : simpleValue(resolved.value), units: 1 }; -} - -function getPointer(root, pointer) { - if (typeof pointer !== 'string' || !pointer.startsWith('/')) { - return { found: false }; - } - if (pointer === '/') { - return { found: true, value: root ?? null }; - } - let current = root; - for (const segment of pointer - .slice(1) - .split('/') - .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'))) { - if (Array.isArray(current)) { - if (!/^(0|[1-9]\d*)$/.test(segment)) { - return { found: false }; - } - const index = Number(segment); - if (index >= current.length) { - return { found: false }; - } - current = current[index]; - } else if ( - current !== null && - typeof current === 'object' && - Object.prototype.hasOwnProperty.call(current, segment) - ) { - current = current[segment]; - } else { - return { found: false }; - } - } - return { found: true, value: current ?? null }; -} - -function normalizePointer(pointer) { - if (pointer === undefined || pointer === null || pointer === '') { - return '/'; - } - if (typeof pointer !== 'string') { - return null; - } - return pointer.startsWith('/') ? pointer : `/${pointer}`; -} - -function simpleValue(node) { - if (node === null || node === undefined) { - return null; - } - if (Array.isArray(node)) { - return node.map(simpleValue); - } - if (typeof node !== 'object') { - return node; - } - if (Object.prototype.hasOwnProperty.call(node, 'value')) { - return node.value ?? null; - } - if (Array.isArray(node.items)) { - return node.items.map(simpleValue); - } - const result = {}; - for (const [key, value] of Object.entries(node)) { - if (BLUE_METADATA_KEYS.has(key)) { - continue; - } - result[key] = simpleValue(value); - } - return result; -} diff --git a/src/test/java/blue/contract/processor/conversation/CounterSnapshotRoundTripStressTest.java b/src/test/java/blue/contract/processor/conversation/CounterSnapshotRoundTripStressTest.java deleted file mode 100644 index e83e1cf..0000000 --- a/src/test/java/blue/contract/processor/conversation/CounterSnapshotRoundTripStressTest.java +++ /dev/null @@ -1,318 +0,0 @@ -package blue.contract.processor.conversation; - -import blue.contract.processor.BlueDocumentProcessors; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.model.TypeBlueId; -import blue.language.processor.DocumentProcessingResult; -import blue.language.processor.HandlerProcessor; -import blue.language.processor.ProcessorExecutionContext; -import blue.language.processor.model.HandlerContract; -import blue.language.processor.model.JsonPatch; -import blue.language.snapshot.ResolvedSnapshot; -import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.Operation; -import blue.repo.v1_3_0.conversation.OperationRequest; -import blue.repo.v1_3_0.conversation.SequentialWorkflowOperation; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.TriggerEvent; -import blue.repo.v1_3_0.conversation.UpdateDocument; -import blue.repo.v1_3_0.core.JsonPatchEntry; -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CounterSnapshotRoundTripStressTest { - private static final int STRESS_ITERATIONS = 100; - private static final String COUNTER_INCREMENT_HANDLER_BLUE_ID = "test-counter-increment-handler"; - - @Test - void noJsCounterUpdatesSurviveCanonicalSnapshotRoundTrips() { - Fixture fixture = configuredFixture(); - DocumentProcessingResult initialized = fixture.blue.initializeDocument( - fixture.blue.preprocess(noJsCounterDocument())); - ResolvedSnapshot currentSnapshot = initialized.snapshot(); - assertNotNull(currentSnapshot); - - long started = System.nanoTime(); - long totalGas = 0L; - long maxGas = 0L; - long minGas = Long.MAX_VALUE; - String finalBlueId = null; - - for (int i = 1; i <= STRESS_ITERATIONS; i++) { - Node event = TestTimelineProvider.timelineEntry(fixture.blue, - fixture.repository, - "counter", - i, - TestTimelineProvider.chatMessage("tick " + i)); - - DocumentProcessingResult result = fixture.blue.processDocument(currentSnapshot, event); - - assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); - assertNotNull(result.blueId(), "iteration " + i + " should return a BlueId"); - assertTrue(result.totalGas() > 0, "iteration " + i + " should charge gas"); - assertEquals(1, result.triggeredEvents().size(), "iteration " + i + " should emit one event"); - assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); - assertCounterMessage(result.triggeredEvents().get(0), i); - - totalGas += result.totalGas(); - maxGas = Math.max(maxGas, result.totalGas()); - minGas = Math.min(minGas, result.totalGas()); - finalBlueId = result.blueId(); - - String canonicalJson = fixture.blue.nodeToJson(result.canonicalDocument()); - Node parsedCanonical = fixture.blue.jsonToNode(canonicalJson); - ResolvedSnapshot loadedSnapshot = fixture.blue.loadSnapshot(parsedCanonical); - - assertEquals(result.blueId(), loadedSnapshot.blueId(), "iteration " + i + " should preserve BlueId"); - assertSnapshotCacheReuse(result.snapshot(), loadedSnapshot); - currentSnapshot = loadedSnapshot; - } - - long elapsedMillis = (System.nanoTime() - started) / 1_000_000L; - assertEquals(BigInteger.valueOf(STRESS_ITERATIONS), currentSnapshot.resolvedNodeAt("/counter").getValue()); - assertNotNull(finalBlueId); - assertTrue(totalGas > 0); - assertTrue(maxGas > 0); - assertTrue(minGas > 0); - assertEquals(minGas, maxGas, "equivalent no-JS increments should charge stable gas"); - - System.out.println("No-JS counter snapshot round-trip stress: iterations=" + STRESS_ITERATIONS - + ", totalGas=" + totalGas - + ", minGas=" + minGas - + ", maxGas=" + maxGas - + ", finalBlueId=" + finalBlueId - + ", elapsedMillis=" + elapsedMillis); - } - - @Test - void quickJsCounterWorkflowSurvivesCanonicalSnapshotRoundTrips() { - Fixture fixture = configuredFixture(); - DocumentProcessingResult initialized = fixture.blue.initializeDocument( - fixture.blue.preprocess(quickJsCounterDocument(fixture.repository))); - ResolvedSnapshot currentSnapshot = initialized.snapshot(); - assertNotNull(currentSnapshot); - - long started = System.nanoTime(); - long totalGas = 0L; - long maxGas = 0L; - long minGas = Long.MAX_VALUE; - String finalBlueId = null; - - for (int i = 1; i <= STRESS_ITERATIONS; i++) { - Node event = TestTimelineProvider.timelineEntry(fixture.blue, - fixture.repository, - "counter", - i, - operationRequest("increment", 1)); - - DocumentProcessingResult result = fixture.blue.processDocument(currentSnapshot, event); - - assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); - assertNotNull(result.blueId(), "iteration " + i + " should return a BlueId"); - assertTrue(result.totalGas() > 0, "iteration " + i + " should charge gas"); - assertEquals(1, result.triggeredEvents().size(), "iteration " + i + " should emit one event"); - assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); - assertCounterMessage(result.triggeredEvents().get(0), i); - - totalGas += result.totalGas(); - maxGas = Math.max(maxGas, result.totalGas()); - minGas = Math.min(minGas, result.totalGas()); - finalBlueId = result.blueId(); - - String canonicalJson = fixture.blue.nodeToJson(result.canonicalDocument()); - Node parsedCanonical = fixture.blue.jsonToNode(canonicalJson); - ResolvedSnapshot loadedSnapshot = fixture.blue.loadSnapshot(parsedCanonical); - - assertEquals(result.blueId(), loadedSnapshot.blueId(), "iteration " + i + " should preserve BlueId"); - assertSnapshotCacheReuse(result.snapshot(), loadedSnapshot); - currentSnapshot = loadedSnapshot; - } - - long elapsedMillis = (System.nanoTime() - started) / 1_000_000L; - assertEquals(BigInteger.valueOf(STRESS_ITERATIONS), currentSnapshot.resolvedNodeAt("/counter").getValue()); - assertNotNull(finalBlueId); - assertTrue(totalGas > 0); - assertTrue(maxGas > 0); - assertTrue(minGas > 0); - assertEquals(minGas, maxGas, "equivalent QuickJS increments should charge stable gas"); - - System.out.println("QuickJS counter snapshot round-trip stress: iterations=" + STRESS_ITERATIONS - + ", totalGas=" + totalGas - + ", minGas=" + minGas - + ", maxGas=" + maxGas - + ", finalBlueId=" + finalBlueId - + ", elapsedMillis=" + elapsedMillis); - } - - private static void assertSnapshotCacheReuse(ResolvedSnapshot expected, ResolvedSnapshot actual) { - if (expected == actual) { - assertSame(expected, actual); - } else { - assertSame(expected.frozenResolvedRoot(), actual.frozenResolvedRoot()); - } - } - - private static Node noJsCounterDocument() { - Map contracts = new LinkedHashMap(); - contracts.put("ownerChannel", TestTimelineProvider.channel("counter")); - contracts.put("incrementImpl", new Node() - .type(new Node().blueId(COUNTER_INCREMENT_HANDLER_BLUE_ID)) - .properties("channel", new Node().value("ownerChannel"))); - - return new Node() - .name("Counter") - .properties("counter", new Node().value(0)) - .properties("contracts", new Node().properties(contracts)); - } - - private static Node quickJsCounterDocument(BlueRepository repository) { - Map contracts = new LinkedHashMap(); - contracts.put("ownerChannel", TestTimelineProvider.channel("counter")); - contracts.put("increment", operationNode(new Operation() - .channel("ownerChannel") - .request(new Node().type("Integer")))); - SequentialWorkflowOperation workflow = new SequentialWorkflowOperation().operation("increment"); - workflow.steps(Arrays.asList(updateDocumentStep(), triggerEventStep())); - contracts.put("incrementImpl", sequentialWorkflowOperationNode(workflow)); - - return new Node() - .blue(repository.typeAliasBlue()) - .name("QuickJS Counter") - .properties("counter", new Node().value(0)) - .properties("contracts", new Node().properties(contracts)); - } - - private static UpdateDocument updateDocumentStep() { - return new UpdateDocument() - .changeset(Arrays.asList(new JsonPatchEntry() - .op("replace") - .path("/counter") - .val(new Node().value("${event.message.request + document('/counter')}")))); - } - - private static TriggerEvent triggerEventStep() { - return new TriggerEvent() - .event(chatMessageNode(new ChatMessage().message("Counter is now ${document('/counter')}"))); - } - - private static Node operationRequest(String operation, int request) { - OperationRequest operationRequest = new OperationRequest() - .operation(operation) - .request(new Node().value(request)); - return new Node() - .type(OperationRequest.qualifiedName()) - .properties("operation", new Node().value(operationRequest.getOperation())) - .properties("request", operationRequest.getRequest()); - } - - private static Node operationNode(Operation operation) { - return new Node() - .type(Operation.qualifiedName()) - .properties("channel", new Node().value(operation.getChannel())) - .properties("request", operation.getRequest()); - } - - private static Node sequentialWorkflowOperationNode(SequentialWorkflowOperation workflow) { - List steps = new ArrayList(); - for (SequentialWorkflowStep step : workflow.getSteps()) { - if (step instanceof UpdateDocument) { - steps.add(updateDocumentStepNode((UpdateDocument) step)); - } else if (step instanceof TriggerEvent) { - steps.add(triggerEventStepNode((TriggerEvent) step)); - } - } - return new Node() - .type(SequentialWorkflowOperation.qualifiedName()) - .properties("operation", new Node().value(workflow.getOperation())) - .properties("steps", new Node().items(steps)); - } - - private static Node updateDocumentStepNode(UpdateDocument step) { - List changeset = new ArrayList(); - for (JsonPatchEntry entry : step.getChangeset()) { - changeset.add(new Node() - .properties("op", new Node().value(entry.getOp())) - .properties("path", new Node().value(entry.getPath())) - .properties("val", entry.getVal())); - } - return new Node() - .type(UpdateDocument.qualifiedName()) - .properties("changeset", new Node().items(changeset)); - } - - private static Node triggerEventStepNode(TriggerEvent step) { - return new Node() - .type(TriggerEvent.qualifiedName()) - .properties("event", step.getEvent()); - } - - private static Node chatMessageNode(ChatMessage chatMessage) { - return new Node() - .type(ChatMessage.qualifiedName()) - .properties("message", new Node().value(chatMessage.getMessage())); - } - - private static void assertCounterMessage(Node event, int counter) { - assertEquals("Counter is now " + counter, event.get("/message")); - } - - private static Fixture configuredFixture() { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); - TestTimelineProvider.registerWith(blue); - blue.registerContractProcessor(new CounterIncrementHandlerProcessor()); - return new Fixture(repository, blue); - } - - @TypeBlueId(CounterSnapshotRoundTripStressTest.COUNTER_INCREMENT_HANDLER_BLUE_ID) - public static final class CounterIncrementHandler extends HandlerContract { - } - - public static final class CounterIncrementHandlerProcessor - implements HandlerProcessor { - - @Override - public Class contractType() { - return CounterIncrementHandler.class; - } - - @Override - public void execute(CounterIncrementHandler contract, ProcessorExecutionContext context) { - Node current = context.documentAt(context.resolvePointer("/counter")); - int value = ((Number) current.getValue()).intValue(); - int next = value + 1; - - context.applyPatch(JsonPatch.replace( - context.resolvePointer("/counter"), - new Node().value(next))); - - context.emitEvent(new Node() - .type("Conversation/Chat Message") - .properties("message", new Node().value("Counter is now " + next))); - } - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java b/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java deleted file mode 100644 index ba58a00..0000000 --- a/src/test/java/blue/contract/processor/conversation/SequentialWorkflowExecutionTest.java +++ /dev/null @@ -1,1167 +0,0 @@ -package blue.contract.processor.conversation; - -import blue.contract.processor.BlueDocumentProcessorOptions; -import blue.contract.processor.BlueDocumentProcessors; -import blue.contract.processor.ConversationProcessors; -import blue.contract.processor.conversation.expression.ExpressionEvaluator; -import blue.contract.processor.conversation.expression.QuickJsExpressionEvaluator; -import blue.contract.processor.conversation.expression.QuickJsExpressionResolver; -import blue.contract.processor.conversation.expression.SimpleExpressionEvaluator; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationRequest; -import blue.contract.processor.conversation.javascript.JavaScriptEvaluationResult; -import blue.contract.processor.conversation.javascript.NodeQuickJsRuntime; -import blue.contract.processor.conversation.javascript.QuickJsGas; -import blue.contract.processor.conversation.javascript.JavaScriptRuntime; -import blue.contract.processor.conversation.workflow.JavaScriptCodeStepExecutor; -import blue.contract.processor.conversation.workflow.SequentialWorkflowRunner; -import blue.contract.processor.conversation.workflow.StepExecutionContext; -import blue.contract.processor.conversation.workflow.UpdateDocumentStepExecutor; -import blue.contract.processor.conversation.workflow.WorkflowStepExecutor; -import blue.contract.processor.conversation.workflow.WorkflowStepResult; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.processor.DocumentProcessingResult; -import blue.language.processor.ProcessorFatalException; -import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.JavaScriptCode; -import blue.repo.v1_3_0.conversation.SequentialWorkflowStep; -import blue.repo.v1_3_0.conversation.UpdateDocument; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; - -class SequentialWorkflowExecutionTest { - - @Test - void sequentialWorkflowOperationDerivesAndMatchesOperationRequest() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 7); - } - - @Test - void wrongOperationDoesNotRun() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, false)); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "decrement", 7); - - assertCounter(processed, 0); - } - - @Test - void wrongRequestTypeDoesNotRun() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); - - Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value("text")); - Node processed = fixture.blue.processDocument(document, event).document(); - - assertCounter(processed, 0); - } - - @Test - void duplicateRequestDoesNotRunTwice() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); - Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value(7)); - - Node afterFirst = fixture.blue.processDocument(document, event).document(); - Node afterSecond = fixture.blue.processDocument(afterFirst, event).document(); - - assertCounter(afterSecond, 7); - } - - @Test - void newerRequestRunsAfterPreviousRequest() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); - Node afterFirst = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - Node afterSecond = processOperationRequest(fixture, afterFirst, "owner", 2, "increment", 5); - - assertCounter(afterSecond, 12); - } - - @Test - void decrementExpressionWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterDocument(fixture.repository, 10, true)); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "decrement", 3); - - assertCounter(processed, 7); - } - - @Test - void multipleUpdateStepsSeePreviousStepState() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, doubleIncrementDocument(fixture.repository)); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 2); - - assertCounter(processed, 4); - } - - @Test - void directSequentialWorkflowExecutesUpdateDocument() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository)); - Node event = chatTimelineEntry(fixture, "owner", 1, "run"); - - Node processed = fixture.blue.processDocument(document, event).document(); - - assertCounter(processed, 5); - } - - @Test - void unsupportedStepFailsExplicitly() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, unsupportedStepDocument(fixture.repository)); - Node event = chatTimelineEntry(fixture, "owner", 1, "run"); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> fixture.blue.processDocument(document, event)); - - assertTrue(ex.getMessage().contains("Unsupported sequential workflow step")); - } - - @Test - void expressionEvaluatorIsSwappable() { - ExpressionEvaluator fixedEvaluator = new ExpressionEvaluator() { - @Override - public Node evaluate(Node value, StepExecutionContext context) { - return new Node().value(42); - } - }; - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList( - new UpdateDocumentStepExecutor(fixedEvaluator))); - Fixture fixture = configuredFixture(runner, null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 0, - new Node().value("${event.message.request + document('/counter')}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 42); - } - - @Test - void blueDocumentProcessorOptionsInjectsJavaScriptRuntime() { - BlueDocumentProcessorOptions options = BlueDocumentProcessorOptions.builder() - .javaScriptRuntime(fixedRuntime(42)) - .build(); - Fixture fixture = configuredFixture(options); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 0, - new Node().value("${1 + 1}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 42); - } - - @Test - void conversationProcessorOptionsInjectsSequentialWorkflowRunner() { - ExpressionEvaluator fixedEvaluator = new ExpressionEvaluator() { - @Override - public Node evaluate(Node value, StepExecutionContext context) { - return new Node().value(42); - } - }; - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList( - new UpdateDocumentStepExecutor(fixedEvaluator))); - BlueDocumentProcessorOptions options = BlueDocumentProcessorOptions.builder() - .sequentialWorkflowRunner(runner) - .build(); - Fixture fixture = configuredConversationFixture(options); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 0, - new Node().value("${event.message.request + document('/counter')}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 42); - } - - @Test - void nonExpressionValuesPassThrough() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 0, - new Node().properties("nested", new Node().value(true)))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertEquals(Boolean.TRUE, processed.get("/counter/nested")); - } - - @Test - void simpleUnsupportedExpressionFails() { - Fixture fixture = configuredFixture(simpleExpressionRunner(), null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${event.message.request * document('/counter')}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("Unsupported expression")); - } - - @Test - void simpleDecimalArithmeticFails() { - Fixture fixture = configuredFixture(simpleExpressionRunner(), null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - new Node().value(new BigDecimal("1.5")), - new Node().value("${event.message.request + document('/counter')}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("not an integer")); - } - - @Test - void simpleMissingEventPathFails() { - Fixture fixture = configuredFixture(simpleExpressionRunner(), null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${event.message.missing + document('/counter')}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("Event expression path not found")); - } - - @Test - void simpleMissingDocumentPathFails() { - Fixture fixture = configuredFixture(simpleExpressionRunner(), null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${event.message.request + document('/missing')}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("resolved to nothing")); - } - - @Test - void stepResultsAreCollected() { - final AtomicReference> seenResults = new AtomicReference>(); - WorkflowStepExecutor first = new WorkflowStepExecutor() { - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof UpdateDocument; - } - - @Override - public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { - return WorkflowStepResult.value("a"); - } - }; - WorkflowStepExecutor second = new WorkflowStepExecutor() { - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof JavaScriptCode; - } - - @Override - public WorkflowStepResult execute(JavaScriptCode step, StepExecutionContext context) { - seenResults.set(context.stepResults()); - return WorkflowStepResult.value("b"); - } - }; - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList(first, second)); - Fixture fixture = configuredFixture(null, runner); - Node document = initializedDocument(fixture, stepResultsDocument(fixture.repository)); - Node event = chatTimelineEntry(fixture, "owner", 1, "run"); - - fixture.blue.processDocument(document, event); - - assertEquals(1, seenResults.get().size()); - assertEquals("a", seenResults.get().get("Step1")); - } - - @Test - void patchPathResolvesAgainstEmbeddedScope() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, embeddedScopeDocument(fixture.repository)); - Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value(7)); - - Node processed = fixture.blue.processDocument(document, event).document(); - - assertEquals(BigInteger.valueOf(100), processed.get("/counter")); - assertEquals(BigInteger.valueOf(7), processed.get("/child/counter")); - } - - @Test - void quickJsSupportsConditionalExpression() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${document('/counter') > 0 ? 'positive' : 'zero'}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertEquals("positive", processed.get("/counter")); - } - - @Test - void quickJsSupportsObjectResult() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 3, - new Node().value("${({ previous: document('/counter'), request: event.message.request })}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertEquals(BigInteger.valueOf(3), processed.get("/counter/previous")); - assertEquals(BigInteger.valueOf(7), processed.get("/counter/request")); - } - - @Test - void eventCanonicalBindingWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 2, - new Node().value("${eventCanonical.message.request.value + document('/counter')}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 9); - } - - @Test - void documentCanonicalBindingWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 6, - new Node().value("${document.canonical('/counter')}"))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - Node counter = processed.getProperties().get("counter"); - assertNotNull(counter); - assertEquals(BigInteger.valueOf(6), counter.getValue()); - assertEquals(INTEGER_TYPE_BLUE_ID, counter.getType().getBlueId()); - } - - @Test - void quickJsStepsBindingWorks() { - WorkflowStepExecutor fakeFirstStep = new WorkflowStepExecutor() { - @Override - public boolean supports(SequentialWorkflowStep step) { - return step instanceof JavaScriptCode; - } - - @Override - public WorkflowStepResult execute(JavaScriptCode step, StepExecutionContext context) { - return WorkflowStepResult.value(5); - } - }; - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList( - fakeFirstStep, - new UpdateDocumentStepExecutor(new QuickJsExpressionEvaluator()))); - Fixture fixture = configuredFixture(null, runner); - Node document = initializedDocument(fixture, quickJsStepsDocument(fixture.repository)); - Node event = chatTimelineEntry(fixture, "owner", 1, "run"); - - Node processed = fixture.blue.processDocument(document, event).document(); - - assertCounter(processed, 6); - } - - @Test - void quickJsGasConversionIsDeterministic() { - assertEquals(0L, QuickJsGas.toWasmFuel(0L)); - assertEquals(3400L, QuickJsGas.toWasmFuel(2L)); - assertEquals(0L, QuickJsGas.toHostGasUsed(0L)); - assertEquals(1L, QuickJsGas.toHostGasUsed(1L)); - assertEquals(1L, QuickJsGas.toHostGasUsed(1700L)); - assertEquals(2L, QuickJsGas.toHostGasUsed(1701L)); - } - - @Test - void quickJsGasIsCharged() { - Fixture quickJsFixture = configuredFixture(); - Node quickJsDocument = initializedDocument(quickJsFixture, - counterDocument(quickJsFixture.repository, 0, false)); - DocumentProcessingResult quickJsResult = quickJsFixture.blue.processDocument( - quickJsDocument, - operationRequestEvent(quickJsFixture, "owner", 1, "increment", new Node().value(7))); - - Fixture simpleFixture = configuredFixture(simpleExpressionRunner(), null); - Node simpleDocument = initializedDocument(simpleFixture, - counterDocument(simpleFixture.repository, 0, false)); - DocumentProcessingResult simpleResult = simpleFixture.blue.processDocument( - simpleDocument, - operationRequestEvent(simpleFixture, "owner", 1, "increment", new Node().value(7))); - - assertTrue(quickJsResult.totalGas() > simpleResult.totalGas()); - } - - @Test - void quickJsRuntimeErrorFailsClearly() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${missing.value}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("QuickJS expression evaluation failed")); - assertTrue(ex.getMessage().contains("missing")); - } - - @Test - void quickJsOutOfGasFailsClearly() { - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList( - new UpdateDocumentStepExecutor(new QuickJsExpressionEvaluator(new NodeQuickJsRuntime(), 1L)))); - Fixture fixture = configuredFixture(runner, null); - Node document = initializedDocument(fixture, expressionDocument(fixture.repository, - 1, - new Node().value("${(() => { while (true) {} })()}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processOperationRequest(fixture, document, "owner", 1, "increment", 7)); - - assertTrue(ex.getMessage().contains("QuickJS expression evaluation failed")); - assertTrue(ex.getMessage().toLowerCase().contains("gas")); - } - - @Test - void javaScriptCodeStepReturnIsVisibleToLaterUpdateExpression() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Compute", "return { doubled: 21 * 2 };"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Compute.doubled}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 42); - } - - @Test - void javaScriptCodeStepSeesUpdatedDocument() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - updateDocumentStep("replace", "/counter", new Node().value(5)), - javaScriptStep("return { events: [{ type: \"Conversation/Chat Message\", message: `counter is ${document('/counter')}` }] };"))); - - DocumentProcessingResult result = processChat(fixture, document, "owner", 1, "run"); - - assertCounter(result.document(), 5); - assertTriggeredChatMessage(result, "counter is 5"); - } - - @Test - void javaScriptCodeStepEmitsEventsFromReturnedContainer() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("return { events: [{ type: \"Conversation/Chat Message\", message: \"Workflow finished\" }] };"))); - - DocumentProcessingResult result = processChat(fixture, document, "owner", 1, "run"); - - assertTriggeredChatMessage(result, "Workflow finished"); - } - - @Test - void fullCounterJavaScriptCodeWorkflowEmitsChatMessage() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterWorkflowDocument(fixture.repository, - 0, - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}")), - javaScriptStep("const message =\n" - + " `Counter was incremented by ${event.message.request} and is now ${document('/counter')}`;\n" - + "return { events: [{ type: \"Conversation/Chat Message\", message }] };"))); - - DocumentProcessingResult result = processOperationRequestResult(fixture, - document, - "owner", - 1, - "increment", - new Node().value(7)); - - assertCounter(result.document(), 7); - assertTriggeredChatMessage(result, "Counter was incremented by 7 and is now 7"); - } - - @Test - void javaScriptCodeEventAndCanonicalBindingsWork() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterWorkflowDocument(fixture.repository, - 0, - javaScriptStep("return {\n" - + " request: event.message.request,\n" - + " canonicalRequest: eventCanonical.message.request.value\n" - + "};"), - updateDocumentStep("replace", "/counter", - new Node().value("${steps.Step1.request + steps.Step1.canonicalRequest}")))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertCounter(processed, 14); - } - - @Test - void documentCanonicalWorksInJavaScriptCodeBlocks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 6, - javaScriptStep("ReadCounter", "const canonical = document.canonical('/counter');\n" - + "return { plain: document('/counter'), canonicalValue: canonical.value };"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.ReadCounter}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertEquals(BigInteger.valueOf(6), processed.get("/counter/plain")); - assertEquals(BigInteger.valueOf(6), processed.get("/counter/canonicalValue")); - } - - @Test - void previousJavaScriptStepResultsWork() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("First", "return { value: 34 };"), - javaScriptStep("Second", "return steps.First.value + 8;"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Second}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 42); - } - - @Test - void returnedJavaScriptObjectsDoNotMutateDocument() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("return { counter: 999 };"))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 0); - } - - @Test - void blankJavaScriptCodeFailsClearly() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep(" "))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processChat(fixture, document, "owner", 1, "run")); - - assertTrue(ex.getMessage().contains("JavaScript Code step must include code to execute")); - } - - @Test - void javaScriptCodeRuntimeErrorFailsClearly() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("throw new Error(\"boom\");"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processChat(fixture, document, "owner", 1, "run")); - - assertTrue(ex.getMessage().contains("JavaScript Code execution failed")); - assertTrue(ex.getMessage().contains("boom")); - } - - @Test - void javaScriptCodeOutOfGasFailsClearly() { - SequentialWorkflowRunner runner = new SequentialWorkflowRunner( - Arrays.>asList( - new JavaScriptCodeStepExecutor(new NodeQuickJsRuntime(), 1L))); - Fixture fixture = configuredFixture(null, runner); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("while (true) {}"))); - - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processChat(fixture, document, "owner", 1, "run")); - - assertTrue(ex.getMessage().contains("JavaScript Code execution failed")); - assertTrue(ex.getMessage().toLowerCase().contains("gas")); - } - - @Test - void deterministicGlobalSurfaceIsUnavailableInJavaScriptCode() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Globals", "return { date: typeof Date, process: typeof process };"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Globals}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertEquals("undefined", processed.get("/counter/date")); - assertEquals("undefined", processed.get("/counter/process")); - } - - @Test - void namedStepResultsWorkInUpdateDocument() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Compute", "return { value: 12 };"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Compute.value + 8}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 20); - } - - @Test - void updateDocumentDoesNotCreateStepResult() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - updateDocumentStep("replace", "/counter", new Node().value(3)), - javaScriptStep("Inspect", "return Object.keys(steps).length;"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Inspect}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 0); - } - - @Test - void javaScriptNullResultIsPreserved() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("MaybeNull", "return null;"), - javaScriptStep("Check", "return Object.prototype.hasOwnProperty.call(steps, 'MaybeNull') && steps.MaybeNull === null;"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Check}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertEquals(Boolean.TRUE, processed.get("/counter")); - } - - @Test - void currentContractBindingWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - "Demo workflow", - javaScriptStep("ReadContract", "return {\n" - + " channel: currentContract.channel,\n" - + " description: currentContract.description,\n" - + " canonicalDescription: currentContractCanonical.description.value\n" - + "};"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.ReadContract}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertEquals("ownerChannel", processed.get("/counter/channel")); - assertEquals("Demo workflow", processed.get("/counter/description")); - assertEquals("Demo workflow", processed.get("/counter/canonicalDescription")); - } - - @Test - void derivedCurrentContractChannelWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, counterWorkflowDocument(fixture.repository, - 0, - javaScriptStep("ReadContract", "return currentContract.channel;"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.ReadContract}")))); - - Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); - - assertEquals("ownerChannel", processed.get("/counter")); - } - - @Test - void documentRelativePointerWorks() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 4, - javaScriptStep("Read", "return document('counter') + document('/counter') + (document().counter === 4 ? 1 : 0);"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Read}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertCounter(processed, 9); - } - - @Test - void documentMissingPointerReturnsNull() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Read", "return {\n" - + " missing: document('/missing') === null,\n" - + " missingCanonical: document.canonical('/missing') === null\n" - + "};"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Read}")))); - - Node processed = processChat(fixture, document, "owner", 1, "run").document(); - - assertEquals(Boolean.TRUE, processed.get("/counter/missing")); - assertEquals(Boolean.TRUE, processed.get("/counter/missingCanonical")); - } - - @Test - void documentEscapedPointerWorks() { - Fixture fixture = configuredFixture(); - Node document = directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Read", "return document('/a~1b') + document('/a~0b');"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Read}"))); - document.properties("a/b", new Node().value(1)); - document.properties("a~b", new Node().value(2)); - Node initialized = initializedDocument(fixture, document); - - Node processed = processChat(fixture, initialized, "owner", 1, "run").document(); - - assertCounter(processed, 3); - } - - @Test - void documentMetadataSegmentsWork() { - Fixture fixture = configuredFixture(); - Node document = directWorkflowStepsDocument(fixture.repository, - 0, - javaScriptStep("Read", "return {\n" - + " name: document('/prop/name'),\n" - + " description: document('/prop/description'),\n" - + " valueOk: document('/prop/value') === 7\n" - + "};"), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Read}"))); - document.properties("prop", new Node() - .name("Prop A") - .description("Demo prop") - .type("Integer") - .value(7)); - Node initialized = initializedDocument(fixture, document); - - Node processed = processChat(fixture, initialized, "owner", 1, "run").document(); - - assertEquals("Prop A", processed.get("/counter/name")); - assertEquals("Demo prop", processed.get("/counter/description")); - assertEquals(Boolean.TRUE, processed.get("/counter/valueOk")); - } - - @Test - void templateExpressionResolverResolvesTemplates() { - QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(); - - Node resolved = resolver.resolve(new Node().value("Prepared ${steps.Prepare.amount} USD"), resolverBindings()); - - assertEquals("Prepared 125 USD", resolved.getValue()); - } - - @Test - void recursiveResolverIncludePredicateRestrictsEvaluation() { - QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(); - Node value = new Node() - .properties("event", new Node() - .properties("message", new Node().value("Prepared ${steps.Prepare.amount} USD"))) - .properties("other", new Node().value("${steps.Prepare.amount}")); - - Node resolved = resolver.resolve(value, resolverBindings(), path -> path.equals("/event") || path.startsWith("/event/"), path -> true); - - assertEquals("Prepared 125 USD", resolved.get("/event/message")); - assertEquals("${steps.Prepare.amount}", resolved.get("/other")); - } - - @Test - void recursiveResolverDescendPredicateCanKeepNestedDocumentsLiteral() { - QuickJsExpressionResolver resolver = new QuickJsExpressionResolver(); - Node value = new Node() - .properties("event", new Node() - .properties("message", new Node().value("Prepared ${steps.Prepare.amount} USD")) - .properties("embedded", new Node() - .properties("contracts", new Node() - .properties("workflow", new Node() - .properties("steps", new Node().items(new Node() - .properties("val", new Node().value("${steps.Prepare.amount}")))))))); - - Node resolved = resolver.resolve(value, - resolverBindings(), - path -> true, - path -> !path.startsWith("/event/embedded")); - - assertEquals("Prepared 125 USD", resolved.get("/event/message")); - assertEquals("${steps.Prepare.amount}", resolved.get("/event/embedded/contracts/workflow/steps/0/val")); - } - - private static Node processOperationRequest(Fixture fixture, - Node document, - String timelineId, - int timestamp, - String operation, - int request) { - return processOperationRequestResult(fixture, - document, - timelineId, - timestamp, - operation, - new Node().value(request)).document(); - } - - private static DocumentProcessingResult processOperationRequestResult(Fixture fixture, - Node document, - String timelineId, - int timestamp, - String operation, - Node request) { - Node event = operationRequestEvent(fixture, timelineId, timestamp, operation, request); - return fixture.blue.processDocument(document, event); - } - - private static DocumentProcessingResult processChat(Fixture fixture, - Node document, - String timelineId, - int timestamp, - String message) { - Node event = chatTimelineEntry(fixture, timelineId, timestamp, message); - return fixture.blue.processDocument(document, event); - } - - private static Node counterDocument(BlueRepository repository, int counter, boolean includeDecrement) { - Map contracts = baseOperationContracts(); - contracts.put("increment", operation("ownerChannel")); - contracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}")))); - if (includeDecrement) { - contracts.put("decrement", operation("ownerChannel")); - contracts.put("decrementImpl", sequentialWorkflowOperation("decrement", - updateDocumentStep("replace", "/counter", - new Node().value("${document('/counter') - event.message.request}")))); - } - return document(repository, counter, contracts); - } - - private static Node counterWorkflowDocument(BlueRepository repository, int counter, Node... steps) { - Map contracts = baseOperationContracts(); - contracts.put("increment", operation("ownerChannel")); - contracts.put("incrementImpl", sequentialWorkflowOperation("increment", steps)); - return document(repository, counter, contracts); - } - - private static Node expressionDocument(BlueRepository repository, int counter, Node value) { - return expressionDocument(repository, new Node().value(counter), value); - } - - private static Node expressionDocument(BlueRepository repository, Node counter, Node value) { - Map contracts = baseOperationContracts(); - contracts.put("increment", operation("ownerChannel")); - contracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", value))); - return document(repository, counter, contracts); - } - - private static Node doubleIncrementDocument(BlueRepository repository) { - Map contracts = baseOperationContracts(); - contracts.put("increment", operation("ownerChannel")); - contracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}")), - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}")))); - return document(repository, 0, contracts); - } - - private static Node directWorkflowDocument(BlueRepository repository) { - Map contracts = baseOperationContracts(); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("ownerChannel")) - .properties("event", new Node() - .properties("message", new Node() - .properties("message", new Node().value("run")))) - .properties("steps", new Node().items( - updateDocumentStep("replace", "/counter", new Node().value(5))))); - return document(repository, 0, contracts); - } - - private static Node directWorkflowStepsDocument(BlueRepository repository, int counter, Node... steps) { - return directWorkflowStepsDocument(repository, counter, null, steps); - } - - private static Node directWorkflowStepsDocument(BlueRepository repository, - int counter, - String description, - Node... steps) { - Map contracts = baseOperationContracts(); - Node workflow = new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("ownerChannel")) - .properties("steps", new Node().items(steps)); - if (description != null) { - workflow.description(description); - } - contracts.put("direct", workflow); - return document(repository, counter, contracts); - } - - private static Node unsupportedStepDocument(BlueRepository repository) { - Map contracts = baseOperationContracts(); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("ownerChannel")) - .properties("steps", new Node().items(new Node() - .type("Conversation/Sequential Workflow Step")))); - return document(repository, 0, contracts); - } - - private static Node stepResultsDocument(BlueRepository repository) { - Map contracts = baseOperationContracts(); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("ownerChannel")) - .properties("steps", new Node().items( - updateDocumentStep("replace", "/counter", new Node().value(1)), - new Node() - .type("Conversation/JavaScript Code") - .properties("code", new Node().value("return {};"))))); - return document(repository, 0, contracts); - } - - private static Node quickJsStepsDocument(BlueRepository repository) { - Map contracts = baseOperationContracts(); - contracts.put("direct", new Node() - .type("Conversation/Sequential Workflow") - .properties("channel", new Node().value("ownerChannel")) - .properties("steps", new Node().items( - new Node() - .type("Conversation/JavaScript Code") - .properties("code", new Node().value("return 5;")), - updateDocumentStep("replace", "/counter", new Node().value("${steps.Step1 + 1}"))))); - return document(repository, 0, contracts); - } - - private static Node embeddedScopeDocument(BlueRepository repository) { - Map childContracts = baseOperationContracts(); - childContracts.put("increment", operation("ownerChannel")); - childContracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}")))); - - Map rootContracts = new LinkedHashMap<>(); - rootContracts.put("embedded", new Node() - .type("Core/Process Embedded") - .properties("paths", new Node().items(new Node().value("/child")))); - - return new Node() - .blue(repository.typeAliasBlue()) - .name("Root") - .properties("counter", new Node().value(100)) - .properties("child", new Node() - .name("Child") - .properties("counter", new Node().value(0)) - .properties("contracts", new Node().properties(childContracts))) - .properties("contracts", new Node().properties(rootContracts)); - } - - private static Map baseOperationContracts() { - Map contracts = new LinkedHashMap<>(); - contracts.put("ownerChannel", TestTimelineProvider.channel("owner")); - return contracts; - } - - private static Node operation(String channel) { - return new Node() - .type("Conversation/Operation") - .properties("channel", new Node().value(channel)) - .properties("request", new Node().type("Integer")); - } - - private static Node sequentialWorkflowOperation(String operation, Node... steps) { - return new Node() - .type("Conversation/Sequential Workflow Operation") - .properties("operation", new Node().value(operation)) - .properties("steps", new Node().items(steps)); - } - - private static Node updateDocumentStep(String op, String path, Node value) { - return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); - } - - private static Node javaScriptStep(String code) { - return new Node() - .type("Conversation/JavaScript Code") - .properties("code", new Node().value(code)); - } - - private static Node javaScriptStep(String name, String code) { - return javaScriptStep(code).name(name); - } - - private static Node document(BlueRepository repository, int counter, Map contracts) { - return document(repository, new Node().value(counter), contracts); - } - - private static Node document(BlueRepository repository, Node counter, Map contracts) { - return new Node() - .blue(repository.typeAliasBlue()) - .name("Counter") - .properties("counter", counter) - .properties("contracts", new Node().properties(contracts)); - } - - private static Node operationRequestEvent(Fixture fixture, - String timelineId, - int timestamp, - String operation, - Node request) { - Node event = new Node() - .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") - .properties("timeline", new Node() - .properties("timelineId", new Node().value(timelineId))) - .properties("timestamp", new Node().value(timestamp)) - .properties("message", new Node() - .type("Conversation/Operation Request") - .properties("operation", new Node().value(operation)) - .properties("request", request)); - return fixture.blue.preprocess(event); - } - - private static Node chatTimelineEntry(Fixture fixture, String timelineId, int timestamp, String message) { - Node event = new Node() - .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") - .properties("timeline", new Node() - .properties("timelineId", new Node().value(timelineId))) - .properties("timestamp", new Node().value(timestamp)) - .properties("message", new Node() - .type("Conversation/Chat Message") - .properties("message", new Node().value(message))); - return fixture.blue.preprocess(event); - } - - private static Node initializedDocument(Fixture fixture, Node document) { - DocumentProcessingResult result = fixture.blue.initializeDocument(fixture.blue.preprocess(document)); - return result.document(); - } - - private static Fixture configuredFixture() { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); - TestTimelineProvider.registerWith(blue); - return new Fixture(repository, blue); - } - - private static Fixture configuredFixture(BlueDocumentProcessorOptions options) { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue, options); - TestTimelineProvider.registerWith(blue); - return new Fixture(repository, blue); - } - - private static Fixture configuredConversationFixture(BlueDocumentProcessorOptions options) { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - ConversationProcessors.registerWith(blue, options); - TestTimelineProvider.registerWith(blue); - return new Fixture(repository, blue); - } - - private static Fixture configuredFixture(SequentialWorkflowRunner operationRunner, - SequentialWorkflowRunner directRunner) { - Fixture fixture = configuredFixture(); - if (operationRunner != null) { - fixture.blue.registerContractProcessor(new SequentialWorkflowOperationProcessor(operationRunner)); - } - if (directRunner != null) { - fixture.blue.registerContractProcessor(new SequentialWorkflowProcessor(directRunner)); - } - return fixture; - } - - private static SequentialWorkflowRunner simpleExpressionRunner() { - return new SequentialWorkflowRunner( - Arrays.>asList( - new UpdateDocumentStepExecutor(new SimpleExpressionEvaluator()))); - } - - private static Map resolverBindings() { - Map prepare = new LinkedHashMap(); - prepare.put("amount", 125); - Map steps = new LinkedHashMap(); - steps.put("Prepare", prepare); - Map bindings = new LinkedHashMap(); - bindings.put("steps", steps); - bindings.put("event", null); - bindings.put("eventCanonical", null); - bindings.put("currentContract", null); - bindings.put("currentContractCanonical", null); - bindings.put("document", null); - bindings.put("documentCanonical", null); - return bindings; - } - - private static JavaScriptRuntime fixedRuntime(final Object value) { - return new JavaScriptRuntime() { - @Override - public JavaScriptEvaluationResult evaluate(JavaScriptEvaluationRequest request) { - return new JavaScriptEvaluationResult(value, QuickJsGas.WASM_FUEL_PER_HOST_GAS_UNIT, 1L); - } - }; - } - - private static void assertCounter(Node document, int expected) { - assertEquals(BigInteger.valueOf(expected), document.get("/counter")); - } - - private static void assertTriggeredChatMessage(DocumentProcessingResult result, String expectedMessage) { - assertEquals(1, result.triggeredEvents().size()); - Node event = result.triggeredEvents().get(0); - assertEquals("Conversation/Chat Message", event.getType().getValue()); - assertEquals(expectedMessage, event.get("/message")); - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java b/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java deleted file mode 100644 index 554e30b..0000000 --- a/src/test/java/blue/contract/processor/conversation/javascript/NodeQuickJsRuntimeTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package blue.contract.processor.conversation.javascript; - -import java.nio.file.Paths; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class NodeQuickJsRuntimeTest { - - @Test - void vmErrorWithGasPreservesGasMetadata() { - NodeQuickJsRuntime runtime = runtime(); - - JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, - () -> runtime.parseResult("{\"ok\":false,\"type\":\"vm-error\",\"message\":\"boom\",\"wasmGasUsed\":\"1700\"}")); - - assertEquals("vm-error: boom", ex.getMessage()); - assertTrue(ex.hasGasUsage()); - assertEquals(1700L, ex.wasmGasUsed()); - assertEquals(1L, ex.hostGasUsed()); - } - - @Test - void bridgeSetupErrorWithoutGasPreservesMessageWithoutFabricatingGas() { - NodeQuickJsRuntime runtime = runtime(); - - JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, - () -> runtime.parseResult("{\"ok\":false,\"message\":\"blue-quickjs root argument is required\"}")); - - assertEquals("blue-quickjs root argument is required", ex.getMessage()); - assertFalse(ex.hasGasUsage()); - } - - @Test - void successfulResponseMissingGasIsMalformed() { - NodeQuickJsRuntime runtime = runtime(); - - JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, - () -> runtime.parseResult("{\"ok\":true,\"value\":1}")); - - assertTrue(ex.getMessage().contains("QuickJS bridge returned invalid wasmGasUsed")); - } - - @Test - void malformedGasValueIsRejected() { - NodeQuickJsRuntime runtime = runtime(); - - JavaScriptExecutionException ex = assertThrows(JavaScriptExecutionException.class, - () -> runtime.parseResult("{\"ok\":false,\"message\":\"boom\",\"wasmGasUsed\":\"not-a-number\"}")); - - assertTrue(ex.getMessage().contains("QuickJS bridge returned invalid wasmGasUsed")); - } - - private static NodeQuickJsRuntime runtime() { - return new NodeQuickJsRuntime(Paths.get("."), Paths.get("."), 1000L); - } -} diff --git a/src/test/java/blue/contract/processor/myos/MyOSTimelineChannelProcessorTest.java b/src/test/java/blue/contract/processor/myos/MyOSTimelineChannelProcessorTest.java deleted file mode 100644 index 54aceac..0000000 --- a/src/test/java/blue/contract/processor/myos/MyOSTimelineChannelProcessorTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package blue.contract.processor.myos; - -import blue.contract.processor.BlueDocumentProcessors; -import blue.language.Blue; -import blue.language.model.Node; -import blue.language.processor.DocumentProcessingResult; -import blue.repo.BlueRepository; -import java.util.LinkedHashMap; -import java.util.Map; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -class MyOSTimelineChannelProcessorTest { - private static final String TIMELINE_ID = "bb13b2d9-3df9-5fea-9fdf-dd4f0ae74486"; - private static final String ACCOUNT_ID = "bbe140c4-7625-41cd-9381-1f677014e996"; - private static final String EMAIL = "alice@example.com"; - - @Test - void matchesTimelineAndAccount() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, ACCOUNT_ID, null))); - - Node processed = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, ACCOUNT_ID, null, 1777987926951095L, "hello")).document(); - - assertNotNull(checkpointEvent(processed)); - assertEquals(TIMELINE_ID, checkpointEvent(processed).getAsText("/timeline/timelineId")); - assertEquals("hello", checkpointEvent(processed).getAsText("/message/message")); - } - - @Test - void rejectsDifferentAccountForSameTimeline() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, ACCOUNT_ID, null))); - - Node processed = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, "other-account", null, 1L, "hello")).document(); - - assertNull(checkpointEvent(processed)); - } - - @Test - void rejectsDifferentTimelineForSameAccount() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, ACCOUNT_ID, null))); - - Node processed = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, "other-timeline", ACCOUNT_ID, null, 1L, "hello")).document(); - - assertNull(checkpointEvent(processed)); - } - - @Test - void rejectsMissingRequiredAccount() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, ACCOUNT_ID, null))); - - Node processed = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, null, null, 1L, "hello")).document(); - - assertNull(checkpointEvent(processed)); - } - - @Test - void rejectsDifferentOrMissingRequiredEmail() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, null, EMAIL))); - - Node differentEmail = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, null, "other@example.com", 1L, "hello")).document(); - Node missingEmail = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, null, null, 2L, "hello")).document(); - - assertNull(checkpointEvent(differentEmail)); - assertNull(checkpointEvent(missingEmail)); - } - - @Test - void matchesByTimelineOnlyWhenNoActorConstraintsAreDeclared() { - Fixture fixture = configuredFixture(); - Node initialized = initializedDocument(fixture, document(fixture.repository, - myosChannel(TIMELINE_ID, null, null))); - - Node processed = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, TIMELINE_ID, "any-account", "any@example.com", 1L, "hello")).document(); - - assertNotNull(checkpointEvent(processed)); - } - - private static Node document(BlueRepository repository, Node ownerChannel) { - Map contracts = new LinkedHashMap(); - contracts.put("ownerChannel", ownerChannel); - return new Node() - .blue(repository.typeAliasBlue()) - .name("MyOS Timeline Test") - .properties("contracts", new Node().properties(contracts)); - } - - private static Node myosChannel(String timelineId, String accountId, String email) { - Node channel = new Node() - .type("MyOS/MyOS Timeline Channel") - .properties("timelineId", new Node().value(timelineId)); - if (accountId != null) { - channel.properties("accountId", new Node().value(accountId)); - } - if (email != null) { - channel.properties("email", new Node().value(email)); - } - return channel; - } - - private static Node myosTimelineEntry(Fixture fixture, - String timelineId, - String accountId, - String email, - long timestamp, - String message) { - Node actor = new Node().type("MyOS/Principal Actor"); - if (accountId != null) { - actor.properties("accountId", new Node().value(accountId)); - } - if (email != null) { - actor.properties("email", new Node().value(email)); - } - Node event = new Node() - .blue(fixture.repository.typeAliasBlue()) - .type("MyOS/MyOS Timeline Entry") - .properties("timeline", new Node() - .properties("timelineId", new Node().value(timelineId))) - .properties("timestamp", new Node().value(timestamp)) - .properties("actor", actor) - .properties("message", new Node() - .type("Conversation/Chat Message") - .properties("message", new Node().value(message))); - return fixture.blue.preprocess(event); - } - - private static Node initializedDocument(Fixture fixture, Node document) { - DocumentProcessingResult result = fixture.blue.initializeDocument(fixture.blue.preprocess(document)); - return result.document(); - } - - private static Node checkpointEvent(Node document) { - Node contracts = property(document, "contracts"); - Node checkpoint = property(contracts, "checkpoint"); - Node lastEvents = property(checkpoint, "lastEvents"); - return property(lastEvents, "ownerChannel"); - } - - private static Node property(Node node, String key) { - if (node == null || node.getProperties() == null) { - return null; - } - return node.getProperties().get(key); - } - - private static Fixture configuredFixture() { - BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); - return new Fixture(repository, blue); - } - - private static final class Fixture { - private final BlueRepository repository; - private final Blue blue; - - private Fixture(BlueRepository repository, Blue blue) { - this.repository = repository; - this.blue = blue; - } - } -} diff --git a/src/test/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessorTest.java b/src/test/java/blue/coordination/processor/CompositeTimelineChannelProcessorTest.java similarity index 70% rename from src/test/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessorTest.java rename to src/test/java/blue/coordination/processor/CompositeTimelineChannelProcessorTest.java index 98cb8b8..1c882c8 100644 --- a/src/test/java/blue/contract/processor/conversation/CompositeTimelineChannelProcessorTest.java +++ b/src/test/java/blue/coordination/processor/CompositeTimelineChannelProcessorTest.java @@ -1,9 +1,10 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; +import blue.coordination.processor.CoordinationProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; import blue.repo.BlueRepository; import java.math.BigInteger; import java.util.Arrays; @@ -15,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class CompositeTimelineChannelProcessorTest { @@ -76,16 +76,18 @@ void duplicateEventIsSkippedPerChildCheckpoint() { Node event = chatTimelineEntry(fixture, "owner", 1, "hello"); DocumentProcessingResult first = fixture.blue.processDocument(initialized, event); - Object firstA = first.document().get("/contracts/checkpoint/lastSignatures/inbox::childA"); - Object firstB = first.document().get("/contracts/checkpoint/lastSignatures/inbox::childB"); + Node firstA = nodeAt(first.document(), "/contracts/checkpoint/lastEvents/inbox::childA"); + Node firstB = nodeAt(first.document(), "/contracts/checkpoint/lastEvents/inbox::childB"); DocumentProcessingResult second = fixture.blue.processDocument(first.document(), event); assertChatCount(first.triggeredEvents(), "composite saw childA", 1); assertChatCount(first.triggeredEvents(), "composite saw childB", 1); assertChatCount(second.triggeredEvents(), "composite saw childA", 0); assertChatCount(second.triggeredEvents(), "composite saw childB", 0); - assertEquals(firstA, second.document().get("/contracts/checkpoint/lastSignatures/inbox::childA")); - assertEquals(firstB, second.document().get("/contracts/checkpoint/lastSignatures/inbox::childB")); + assertEquals(firstA.get("/timestamp"), + nodeAt(second.document(), "/contracts/checkpoint/lastEvents/inbox::childA/timestamp").getValue()); + assertEquals(firstB.get("/timestamp"), + nodeAt(second.document(), "/contracts/checkpoint/lastEvents/inbox::childB/timestamp").getValue()); } @Test @@ -117,11 +119,10 @@ void missingChildChannelFailsClearly() { timelineChannel("owner"), timelineChannel("support"))); - RuntimeException ex = assertThrows(RuntimeException.class, - () -> processChat(fixture, initialized, "owner", 1, "hello")); + DocumentProcessingResult result = processChat(fixture, initialized, "owner", 1, "hello"); - assertTrue(ex.getMessage().contains("Composite Timeline Channel")); - assertTrue(ex.getMessage().contains("missing")); + assertRuntimeFatal(result, "Composite Timeline Channel"); + assertRuntimeFatal(result, "missing"); } @Test @@ -129,15 +130,14 @@ void unsupportedChildChannelFailsClearly() { Fixture fixture = configuredFixture(); Map contracts = new LinkedHashMap(); contracts.put("owner", timelineChannel("owner")); - contracts.put("triggered", new Node().type("Core/Triggered Event Channel")); + contracts.put("triggered", new Node().type("Triggered Event Channel")); contracts.put("inbox", compositeTimelineChannel(Arrays.asList("triggered"))); contracts.put("handler", compositeHandler()); Node initialized = initializedDocument(fixture, document(fixture.repository, contracts)); - RuntimeException ex = assertThrows(RuntimeException.class, - () -> processChat(fixture, initialized, "owner", 1, "hello")); + DocumentProcessingResult result = processChat(fixture, initialized, "owner", 1, "hello"); - assertTrue(ex.getMessage().contains("No processor registered")); + assertRuntimeFatal(result, "No processor registered"); } @Test @@ -148,20 +148,19 @@ void selfReferenceFailsClearly() { timelineChannel("owner"), timelineChannel("support"))); - RuntimeException ex = assertThrows(RuntimeException.class, - () -> processChat(fixture, initialized, "owner", 1, "hello")); + DocumentProcessingResult result = processChat(fixture, initialized, "owner", 1, "hello"); - assertTrue(ex.getMessage().contains("cannot include itself")); + assertRuntimeFatal(result, "cannot include itself"); } @Test void childChannelEventFilterIsHonored() { Fixture fixture = configuredFixture(); Node filtered = timelineChannel("owner") - .properties("event", new Node() - .type("Conversation/Timeline Entry") + .properties("definition", new Node() + .type("Coordination/Timeline Entry") .properties("message", new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value("allowed")))); Node initialized = initializedDocument(fixture, compositeDocument(fixture.repository, Arrays.asList("owner"), @@ -202,7 +201,7 @@ private static Node timelineChannel(String timelineId) { private static Node compositeTimelineChannel(List channels) { return new Node() - .type("Conversation/Composite Timeline Channel") + .type("Coordination/Composite Timeline Channel") .properties("channels", new Node().items(channelNodes(channels))); } @@ -216,33 +215,51 @@ private static Node[] channelNodes(List channels) { private static Node compositeHandler() { return new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("inbox")) .properties("steps", new Node().items( - triggerEventStep(chatMessageEvent("composite saw ${event.meta.compositeSourceChannelKey}")))); + computeAppendChatMessageStep(bexConcat( + new Node().value("composite saw "), + bexBinding("event", "/meta/compositeSourceChannelKey"))))); } - private static Node triggerEventStep(Node event) { + private static Node computeAppendChatMessageStep(Node message) { return new Node() - .type("Conversation/Trigger Event") - .properties("event", event); + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendEvent", chatMessageBexEvent(message)), + new Node().properties("$return", new Node().value(true)))); } private static Node chatMessageEvent(String message) { return new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value(message)); } + private static Node chatMessageBexEvent(Node message) { + return new Node().properties("$merge", new Node().items( + new Node().properties("type", new Node().value("Coordination/Chat Message")), + new Node().properties("message", message))); + } + + private static Node bexConcat(Node... values) { + return new Node().properties("$concat", new Node().items(values)); + } + + private static Node bexBinding(String name, String path) { + return new Node().properties("$binding", new Node().value(name + path)); + } + private static Node chatTimelineEntry(Fixture fixture, String timelineId, int timestamp, String message) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value(timelineId))) .properties("timestamp", new Node().value(timestamp)) .properties("message", chatMessageEvent(message)); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static DocumentProcessingResult processChat(Fixture fixture, @@ -258,12 +275,43 @@ private static Node initializedDocument(Fixture fixture, Node document) { } private static Node nodeAt(Node node, String pointer) { - try { - Object value = node.get(pointer); - return value instanceof Node ? (Node) value : null; - } catch (IllegalArgumentException ex) { + if (node == null) { return null; } + if ("/".equals(pointer)) { + return node; + } + Node current = node; + String[] segments = pointer.substring(1).split("/"); + for (String rawSegment : segments) { + String segment = rawSegment.replace("~1", "/").replace("~0", "~"); + if ("contracts".equals(segment)) { + current = current.getContracts(); + } else if (current.getProperties() != null && current.getProperties().containsKey(segment)) { + current = current.getProperties().get(segment); + } else if (current.getItems() != null && isArrayIndex(segment)) { + int index = Integer.parseInt(segment); + current = index < current.getItems().size() ? current.getItems().get(index) : null; + } else { + return null; + } + if (current == null) { + return null; + } + } + return current; + } + + private static boolean isArrayIndex(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; } private static void assertChatCount(List events, String message, int expected) { @@ -279,11 +327,16 @@ private static void assertChatCount(List events, String message, int expec assertEquals(expected, count); } + private static void assertRuntimeFatal(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null && result.failureReason().contains(expectedMessage), + result.failureReason()); + } + private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } diff --git a/src/test/java/blue/contract/processor/BlueDocumentProcessorsTest.java b/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java similarity index 77% rename from src/test/java/blue/contract/processor/BlueDocumentProcessorsTest.java rename to src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java index 4e8ecc3..633efe6 100644 --- a/src/test/java/blue/contract/processor/BlueDocumentProcessorsTest.java +++ b/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java @@ -1,4 +1,4 @@ -package blue.contract.processor; +package blue.coordination.processor; import blue.language.Blue; import blue.language.model.Node; @@ -10,16 +10,14 @@ import blue.language.processor.model.MarkerContract; import blue.language.utils.TypeClassResolver; import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.CompositeTimelineChannel; -import blue.repo.v1_3_0.conversation.JavaScriptCode; -import blue.repo.v1_3_0.conversation.Operation; -import blue.repo.v1_3_0.conversation.OperationRequest; -import blue.repo.v1_3_0.conversation.SequentialWorkflow; -import blue.repo.v1_3_0.conversation.SequentialWorkflowOperation; -import blue.repo.v1_3_0.conversation.TimelineChannel; -import blue.repo.v1_3_0.conversation.UpdateDocument; -import blue.repo.v1_3_0.myos.MyOSTimelineChannel; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.CompositeTimelineChannel; +import blue.repo.coordination.Operation; +import blue.repo.coordination.OperationRequest; +import blue.repo.coordination.SequentialWorkflow; +import blue.repo.coordination.SequentialWorkflowOperation; +import blue.repo.coordination.TimelineChannel; +import blue.repo.coordination.UpdateDocument; import java.math.BigInteger; import java.util.Collections; import java.util.LinkedHashMap; @@ -32,31 +30,32 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class BlueDocumentProcessorsTest { +class CoordinationProcessorsTest { @Test - void registerWithBlueRegistersConversationProcessors() { + void registerWithBlueRegistersCoordinationProcessors() { Fixture fixture = configuredFixture(); - assertConversationProcessorsRegistered(fixture.blue.getDocumentProcessor()); + assertCoordinationProcessorsRegistered(fixture.blue.getDocumentProcessor()); } @Test - void configureBuilderRegistersConversationProcessors() { + void configureBuilderRegistersCoordinationProcessors() { DocumentProcessor processor = - BlueDocumentProcessors.configure(DocumentProcessor.builder()).build(); + CoordinationProcessors.configure(DocumentProcessor.builder()).build(); - assertConversationProcessorsRegistered(processor); + assertCoordinationProcessorsRegistered(processor); } @Test - void realRepositoryConversationContractsLoadAndInitialize() { + void realRepositoryCoordinationContractsLoadAndInitialize() { Fixture fixture = configuredFixture(); + TestTimelineProvider.registerWith(fixture.blue); Node document = counterDocument(fixture.repository, "ownerChannel"); Node preprocessed = fixture.blue.preprocess(document.clone()); Map contracts = contracts(preprocessed); - assertEquals(MyOSTimelineChannel.blueId(), contracts.get("ownerChannel").getType().getBlueId()); + assertEquals(TimelineChannel.blueId(), contracts.get("ownerChannel").getType().getBlueId()); assertEquals(Operation.blueId(), contracts.get("increment").getType().getBlueId()); assertEquals(SequentialWorkflowOperation.blueId(), contracts.get("incrementImpl").getType().getBlueId()); @@ -83,6 +82,7 @@ void realRepositoryConversationContractsLoadAndInitialize() { @Test void initializationFailsWhenSequentialWorkflowOperationDerivesMissingChannel() { Fixture fixture = configuredFixture(); + TestTimelineProvider.registerWith(fixture.blue); Node document = counterDocument(fixture.repository, "missingChannel"); Node preprocessed = fixture.blue.preprocess(document.clone()); @@ -103,28 +103,25 @@ void generatedRepositoryContractsProvideProcessorModelBaseTypes() { } @Test - void generatedConversationTypesResolveToRepositoryClasses() { + void generatedCoordinationTypesResolveToRepositoryClasses() { TypeClassResolver resolver = BlueRepository.v1_3_0().typeClassResolver(); assertEquals(TimelineChannel.class, resolver.resolveClass(TimelineChannel.blueId())); assertEquals(CompositeTimelineChannel.class, resolver.resolveClass(CompositeTimelineChannel.blueId())); - assertEquals(MyOSTimelineChannel.class, resolver.resolveClass(MyOSTimelineChannel.blueId())); assertEquals(Operation.class, resolver.resolveClass(Operation.blueId())); assertEquals(SequentialWorkflow.class, resolver.resolveClass(SequentialWorkflow.blueId())); assertEquals(SequentialWorkflowOperation.class, resolver.resolveClass(SequentialWorkflowOperation.blueId())); assertEquals(UpdateDocument.class, resolver.resolveClass(UpdateDocument.blueId())); - assertEquals(JavaScriptCode.class, resolver.resolveClass(JavaScriptCode.blueId())); assertEquals(ChatMessage.class, resolver.resolveClass(ChatMessage.blueId())); assertEquals(OperationRequest.class, resolver.resolveClass(OperationRequest.blueId())); } - private static void assertConversationProcessorsRegistered(DocumentProcessor processor) { + private static void assertCoordinationProcessorsRegistered(DocumentProcessor processor) { ContractProcessorRegistry registry = processor.getContractRegistry(); assertFalse(registry.lookupChannel(TimelineChannel.blueId()).isPresent()); - assertTrue(registry.lookupChannel(MyOSTimelineChannel.blueId()).isPresent()); assertTrue(registry.lookupChannel(CompositeTimelineChannel.blueId()).isPresent()); assertTrue(registry.lookupMarker(Operation.blueId()).isPresent()); assertTrue(registry.lookupHandler(SequentialWorkflow.blueId()).isPresent()); @@ -133,24 +130,22 @@ private static void assertConversationProcessorsRegistered(DocumentProcessor pro private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); return new Fixture(repository, blue); } private static Node counterDocument(BlueRepository repository, String operationChannel) { Map contracts = new LinkedHashMap<>(); contracts.put("ownerChannel", new Node() - .type("MyOS/MyOS Timeline Channel") - .properties("timelineId", new Node().value("owner")) - .properties("accountId", new Node().value("account"))); + .type("Coordination/Timeline Channel") + .properties("timelineId", new Node().value("owner"))); contracts.put("increment", new Node() - .type("Conversation/Operation") + .type("Coordination/Operation") .properties("channel", new Node().value(operationChannel)) .properties("request", new Node().type("Integer"))); contracts.put("incrementImpl", new Node() - .type("Conversation/Sequential Workflow Operation") + .type("Coordination/Sequential Workflow Operation") .properties("operation", new Node().value("increment")) .properties("steps", new Node().items(Collections.emptyList()))); @@ -162,7 +157,7 @@ private static Node counterDocument(BlueRepository repository, String operationC } private static Map contracts(Node document) { - return document.getProperties().get("contracts").getProperties(); + return document.getContracts().getProperties(); } private static final class Fixture { diff --git a/src/test/java/blue/coordination/processor/CoordinationTestResources.java b/src/test/java/blue/coordination/processor/CoordinationTestResources.java new file mode 100644 index 0000000..2ba7b47 --- /dev/null +++ b/src/test/java/blue/coordination/processor/CoordinationTestResources.java @@ -0,0 +1,133 @@ +package blue.coordination.processor; + +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.language.Blue; +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.processor.registry.BlueRuntimeTypeRegistry; +import blue.language.provider.BootstrapProvider; +import blue.language.provider.SequentialNodeProvider; +import blue.language.utils.NodeProviderWrapper; +import blue.repo.BlueRepository; +import blue.repo.coordination.TimelineChannel; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class CoordinationTestResources { + private static final String LEGACY_SAMPLE_NAMESPACE = "My" + "OS"; + private static final String LEGACY_SAMPLE_CAMEL = "my" + "Os"; + private static final String LEGACY_SAMPLE_LOWER = "my" + "os"; + + private CoordinationTestResources() { + } + + public static String readResource(String resourcePath) { + String normalizedPath = normalizeResourcePath(resourcePath); + InputStream stream = CoordinationTestResources.class.getClassLoader() + .getResourceAsStream(normalizedPath); + if (stream == null) { + throw new IllegalArgumentException("Missing test resource: " + resourcePath); + } + try (InputStream input = stream; ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new IllegalStateException("Failed to read test resource: " + resourcePath, ex); + } + } + + public static Node yamlResource(Blue blue, BlueRepository repository, String resourcePath) { + Node node = blue.parseSourceYaml(readResource(resourcePath)); + node.blue(repository.typeAliasBlue()); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor(testTypeAliases(repository)).preprocess(node); + return blue.preprocess(aliasesResolved); + } + + public static Map testTypeAliases(BlueRepository repository) { + Map aliases = repository != null && repository.typeAliases() != null + ? new LinkedHashMap(repository.typeAliases()) + : new LinkedHashMap(); + Map additionalAliases = new LinkedHashMap(); + for (Map.Entry entry : aliases.entrySet()) { + String alias = entry.getKey(); + String neutralAlias = neutralSampleAlias(alias); + if (!alias.equals(neutralAlias)) { + additionalAliases.put(neutralAlias, entry.getValue()); + } + } + aliases.putAll(additionalAliases); + return aliases; + } + + public static Blue configuredBlue(BlueRepository repository) { + Blue blue = repository.configure(new Blue()); + NodeProvider provider = new SequentialNodeProvider(Arrays.asList( + BootstrapProvider.INSTANCE, + BlueRuntimeTypeRegistry.getDefault().asProvider(), + NodeProviderWrapper.unverified(repository.nodeProvider()))); + blue.nodeProvider(provider); + return blue; + } + + public static String simpleTimelineChannelYaml(String key, String timelineId, int indent) { + String base = spaces(indent); + String child = spaces(indent + 2); + return String.join("\n", + base + key + ":", + child + "type: " + TimelineChannel.qualifiedName(), + child + "timelineId: " + timelineId); + } + + public static Node operationRequest(String operation, Node request) { + Node safeRequest = request != null ? request : new Node(); + return new Node() + .type("Coordination/Operation Request") + .properties("operation", new Node().value(operation)) + .properties("request", safeRequest); + } + + public static Node operationRequestEvent(Blue blue, + BlueRepository repository, + String timelineId, + int timestamp, + String operation, + Node request) { + return TestTimelineProvider.timelineEntry(blue, + repository, + timelineId, + timestamp, + operationRequest(operation, request)); + } + + private static String normalizeResourcePath(String resourcePath) { + if (resourcePath == null) { + throw new IllegalArgumentException("resourcePath must not be null"); + } + return resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath; + } + + private static String neutralSampleAlias(String alias) { + return alias.replace(LEGACY_SAMPLE_NAMESPACE, "Sample") + .replace(LEGACY_SAMPLE_CAMEL, "sample") + .replace(LEGACY_SAMPLE_LOWER, "sample"); + } + + private static String spaces(int count) { + if (count <= 0) { + return ""; + } + char[] chars = new char[count]; + Arrays.fill(chars, ' '); + return new String(chars); + } +} diff --git a/src/test/java/blue/coordination/processor/CounterSnapshotRoundTripStressTest.java b/src/test/java/blue/coordination/processor/CounterSnapshotRoundTripStressTest.java new file mode 100644 index 0000000..2431a1a --- /dev/null +++ b/src/test/java/blue/coordination/processor/CounterSnapshotRoundTripStressTest.java @@ -0,0 +1,164 @@ +package blue.coordination.processor; + +import blue.coordination.processor.CoordinationProcessors; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.HandlerProcessor; +import blue.language.processor.ProcessorExecutionContext; +import blue.language.processor.model.HandlerContract; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.ResolvedSnapshot; +import blue.repo.BlueRepository; +import blue.repo.coordination.ChatMessage; +import java.math.BigInteger; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CounterSnapshotRoundTripStressTest { + private static final int STRESS_ITERATIONS = 100; + + @Test + void bexOnlyCounterUpdatesSurviveCanonicalSnapshotRoundTrips() { + Fixture fixture = configuredFixture(); + DocumentProcessingResult initialized = fixture.blue.initializeDocument( + fixture.blue.preprocess(bexOnlyCounterDocument(fixture.counterIncrementHandlerBlueId) + .blue(fixture.repository.typeAliasBlue()))); + ResolvedSnapshot currentSnapshot = initialized.snapshot(); + assertNotNull(currentSnapshot); + + long started = System.nanoTime(); + long totalGas = 0L; + long maxGas = 0L; + long minGas = Long.MAX_VALUE; + String finalBlueId = null; + + for (int i = 1; i <= STRESS_ITERATIONS; i++) { + Node event = TestTimelineProvider.timelineEntry(fixture.blue, + fixture.repository, + "counter", + i, + TestTimelineProvider.chatMessage("tick " + i)); + + DocumentProcessingResult result = fixture.blue.processDocument(currentSnapshot, event); + + assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); + assertNotNull(result.blueId(), "iteration " + i + " should return a BlueId"); + assertTrue(result.totalGas() > 0, "iteration " + i + " should charge gas"); + assertEquals(1, result.triggeredEvents().size(), "iteration " + i + " should emit one event"); + assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); + assertCounterMessage(result.triggeredEvents().get(0), i); + + totalGas += result.totalGas(); + maxGas = Math.max(maxGas, result.totalGas()); + minGas = Math.min(minGas, result.totalGas()); + finalBlueId = result.blueId(); + + String canonicalJson = fixture.blue.nodeToJson(result.canonicalDocument()); + Node parsedCanonical = fixture.blue.jsonToNode(canonicalJson); + ResolvedSnapshot loadedSnapshot = fixture.blue.loadSnapshot(parsedCanonical); + + assertEquals(result.blueId(), loadedSnapshot.blueId(), "iteration " + i + " should preserve BlueId"); + assertSnapshotCacheReuse(result.snapshot(), loadedSnapshot); + currentSnapshot = loadedSnapshot; + } + + long elapsedMillis = (System.nanoTime() - started) / 1_000_000L; + assertEquals(BigInteger.valueOf(STRESS_ITERATIONS), currentSnapshot.resolvedNodeAt("/counter").getValue()); + assertNotNull(finalBlueId); + assertTrue(totalGas > 0); + assertTrue(maxGas > 0); + assertTrue(minGas > 0); + assertEquals(minGas, maxGas, "equivalent BEX-only increments should charge stable gas"); + + System.out.println("BEX-only counter snapshot round-trip stress: iterations=" + STRESS_ITERATIONS + + ", totalGas=" + totalGas + + ", minGas=" + minGas + + ", maxGas=" + maxGas + + ", finalBlueId=" + finalBlueId + + ", elapsedMillis=" + elapsedMillis); + } + + private static void assertSnapshotCacheReuse(ResolvedSnapshot expected, ResolvedSnapshot actual) { + if (expected == actual) { + assertSame(expected, actual); + } else { + assertSame(expected.frozenResolvedRoot(), actual.frozenResolvedRoot()); + } + } + + private static Node bexOnlyCounterDocument(String counterIncrementHandlerBlueId) { + Map contracts = new LinkedHashMap(); + contracts.put("ownerChannel", TestTimelineProvider.channel("counter")); + contracts.put("incrementImpl", new Node() + .type(new Node().blueId(counterIncrementHandlerBlueId)) + .properties("channel", new Node().value("ownerChannel"))); + + return new Node() + .name("Counter") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(contracts)); + } + + private static void assertCounterMessage(Node event, int counter) { + assertEquals("Counter is now " + counter, event.get("/message")); + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); + TestTimelineProvider.registerWith(blue); + Node counterIncrementHandlerType = new Node().name("Counter Increment Handler"); + String counterIncrementHandlerBlueId = blue.calculateBlueId(counterIncrementHandlerType); + blue.registerExternalContractType(counterIncrementHandlerBlueId, + counterIncrementHandlerType, + new CounterIncrementHandlerProcessor()); + return new Fixture(repository, blue, counterIncrementHandlerBlueId); + } + + public static final class CounterIncrementHandler extends HandlerContract { + } + + public static final class CounterIncrementHandlerProcessor + implements HandlerProcessor { + + @Override + public Class contractType() { + return CounterIncrementHandler.class; + } + + @Override + public void execute(CounterIncrementHandler contract, ProcessorExecutionContext context) { + Node current = context.documentAt(context.resolvePointer("/counter")); + int value = ((Number) current.getValue()).intValue(); + int next = value + 1; + + context.applyPatch(JsonPatch.replace( + context.resolvePointer("/counter"), + new Node().value(next))); + + context.emitEvent(new Node() + .type(new Node().blueId(ChatMessage.blueId())) + .properties("message", new Node().value("Counter is now " + next))); + } + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + private final String counterIncrementHandlerBlueId; + + private Fixture(BlueRepository repository, Blue blue, String counterIncrementHandlerBlueId) { + this.repository = repository; + this.blue = blue; + this.counterIncrementHandlerBlueId = counterIncrementHandlerBlueId; + } + } +} diff --git a/src/test/java/blue/contract/processor/MustUnderstandContractsTest.java b/src/test/java/blue/coordination/processor/MustUnderstandContractsTest.java similarity index 70% rename from src/test/java/blue/contract/processor/MustUnderstandContractsTest.java rename to src/test/java/blue/coordination/processor/MustUnderstandContractsTest.java index 734853c..d04f130 100644 --- a/src/test/java/blue/contract/processor/MustUnderstandContractsTest.java +++ b/src/test/java/blue/coordination/processor/MustUnderstandContractsTest.java @@ -1,6 +1,5 @@ -package blue.contract.processor; +package blue.coordination.processor; -import blue.contract.processor.conversation.TestTimelineProvider; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; @@ -12,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class MustUnderstandContractsTest { @@ -19,17 +19,18 @@ class MustUnderstandContractsTest { void unknownContractTypeStopsInitialization() { Fixture fixture = configuredFixture(false); Node document = document(fixture.repository, contract("unknown", new Node() - .type(new Node().blueId("unknown-contract-blue-id")))); + .type(new Node().blueId("3nxchG67TRi4XrYFM2MTjj4LmuHNQzVv9NZLjATrPN19")))); - DocumentProcessingResult result = initialize(fixture, document); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> initialize(fixture, document)); - assertCapabilityFailure(result, "Unsupported contract type"); + assertTrue(ex.getMessage().contains("No content found for blueId")); } @Test - void coreChannelContractStopsInitializationWhenUsedAsExecutableContract() { + void baseChannelContractStopsInitializationWhenUsedAsExecutableContract() { Fixture fixture = configuredFixture(false); - Node document = document(fixture.repository, contract("owner", new Node().type("Core/Channel"))); + Node document = document(fixture.repository, contract("owner", new Node().type("Channel"))); DocumentProcessingResult result = initialize(fixture, document); @@ -37,10 +38,10 @@ void coreChannelContractStopsInitializationWhenUsedAsExecutableContract() { } @Test - void abstractConversationTimelineChannelStopsInitializationWhenUsedDirectly() { + void abstractCoordinationTimelineChannelStopsInitializationWhenUsedDirectly() { Fixture fixture = configuredFixture(false); Node document = document(fixture.repository, contract("owner", new Node() - .type("Conversation/Timeline Channel") + .type("Coordination/Timeline Channel") .properties("timelineId", new Node().value("owner")))); DocumentProcessingResult result = initialize(fixture, document); @@ -52,10 +53,10 @@ void abstractConversationTimelineChannelStopsInitializationWhenUsedDirectly() { void handlerBoundToAbstractTimelineChannelFailsClearly() { Fixture fixture = configuredFixture(false); Map contracts = contract("owner", new Node() - .type("Conversation/Timeline Channel") + .type("Coordination/Timeline Channel") .properties("timelineId", new Node().value("owner"))); contracts.put("handler", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("owner")) .properties("steps", new Node().items())); Node document = document(fixture.repository, contracts); @@ -71,7 +72,7 @@ void handlerBoundToTypelessContractFailsClearly() { Map contracts = contract("owner", new Node() .properties("timelineId", new Node().value("owner"))); contracts.put("handler", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("owner")) .properties("steps", new Node().items())); Node document = document(fixture.repository, contracts); @@ -98,22 +99,6 @@ void simpleTimelineProviderWorksWhenRegistered() { assertNotNull(checkpointEvent(result.document(), "owner")); } - @Test - void myosTimelineProviderWorksByDefault() { - Fixture fixture = configuredFixture(false); - Node document = document(fixture.repository, contract("owner", new Node() - .type("MyOS/MyOS Timeline Channel") - .properties("timelineId", new Node().value("owner")) - .properties("accountId", new Node().value("account")))); - Node initialized = initialize(fixture, document).document(); - - DocumentProcessingResult result = fixture.blue.processDocument(initialized, - myosTimelineEntry(fixture, "owner", "account", 1)); - - assertFalse(result.capabilityFailure(), result.failureReason()); - assertNotNull(checkpointEvent(result.document(), "owner")); - } - private static DocumentProcessingResult initialize(Fixture fixture, Node document) { return fixture.blue.initializeDocument(fixture.blue.preprocess(document)); } @@ -138,20 +123,6 @@ private static Node checkpointEvent(Node document, String key) { return property(lastEvents, key); } - private static Node myosTimelineEntry(Fixture fixture, String timelineId, String accountId, int timestamp) { - Node event = new Node() - .blue(fixture.repository.typeAliasBlue()) - .type("MyOS/MyOS Timeline Entry") - .properties("timeline", new Node() - .properties("timelineId", new Node().value(timelineId))) - .properties("timestamp", new Node().value(timestamp)) - .properties("actor", new Node() - .type("MyOS/Principal Actor") - .properties("accountId", new Node().value(accountId))) - .properties("message", TestTimelineProvider.chatMessage("hello")); - return fixture.blue.preprocess(event); - } - private static Map contract(String key, Node contract) { Map contracts = new LinkedHashMap(); contracts.put(key, contract); @@ -166,7 +137,13 @@ private static Node document(BlueRepository repository, Map contra } private static Node property(Node node, String key) { - if (node == null || node.getProperties() == null) { + if (node == null) { + return null; + } + if ("contracts".equals(key)) { + return node.getContracts(); + } + if (node.getProperties() == null) { return null; } return node.getProperties().get(key); @@ -174,9 +151,8 @@ private static Node property(Node node, String key) { private static Fixture configuredFixture(boolean simpleTimelineProvider) { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); if (simpleTimelineProvider) { TestTimelineProvider.registerWith(blue); } diff --git a/src/test/java/blue/contract/processor/conversation/OperationRequestMatchingTest.java b/src/test/java/blue/coordination/processor/OperationRequestMatchingTest.java similarity index 80% rename from src/test/java/blue/contract/processor/conversation/OperationRequestMatchingTest.java rename to src/test/java/blue/coordination/processor/OperationRequestMatchingTest.java index 556ef8a..4c1e8fb 100644 --- a/src/test/java/blue/contract/processor/conversation/OperationRequestMatchingTest.java +++ b/src/test/java/blue/coordination/processor/OperationRequestMatchingTest.java @@ -1,6 +1,6 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; +import blue.coordination.processor.CoordinationProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.model.Schema; @@ -22,8 +22,7 @@ void directOperationRequestRunsThroughTriggeredChannel() { contracts.put("triggered", triggeredChannel()); contracts.put("increment", operation("triggered", integerPattern())); contracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.request + document('/counter')}")))); + updateDocumentStep("replace", "/counter", directOperationIncrementValue()))); contracts.put("producer", directWorkflow("owner", triggerEventStep(operationRequestEventNode("increment", new Node().value(7))))); Node initialized = initializedDocument(fixture, document(fixture.repository, 0, contracts)); @@ -39,8 +38,7 @@ void timelineEntryOperationRequestStillRuns() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())))); Node processed = processOperationRequest(fixture, initialized, "owner", 1, "increment", new Node().value(7)); @@ -51,9 +49,9 @@ void timelineEntryOperationRequestStillRuns() { void sequentialWorkflowOperationEventPatternAllowsMatchingEvent() { Fixture fixture = configuredFixture(); Node workflow = sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())); workflow.properties("event", new Node() - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("source", new Node() .properties("value", new Node().value("web")))); Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, @@ -69,9 +67,9 @@ void sequentialWorkflowOperationEventPatternAllowsMatchingEvent() { void sequentialWorkflowOperationEventPatternRejectsDifferentEvent() { Fixture fixture = configuredFixture(); Node workflow = sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())); workflow.properties("event", new Node() - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("source", new Node() .properties("value", new Node().value("web")))); Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, @@ -89,7 +87,7 @@ void operationHandlerCanDeriveChannelFromOperation() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())))); Node processed = processOperationRequest(fixture, initialized, "owner", 1, "increment", new Node().value(7)); @@ -100,7 +98,7 @@ void operationHandlerCanDeriveChannelFromOperation() { void operationHandlerCanDeclareSameChannelAsOperation() { Fixture fixture = configuredFixture(); Node workflow = sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())); workflow.properties("channel", new Node().value("owner")); Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), @@ -115,7 +113,7 @@ void operationHandlerCanDeclareSameChannelAsOperation() { void operationWithoutChannelCanUseExplicitHandlerChannel() { Fixture fixture = configuredFixture(); Node workflow = sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())); workflow.properties("channel", new Node().value("owner")); Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation(null, integerPattern()), @@ -133,7 +131,7 @@ void conflictingOperationAndHandlerChannelsDoNotRun() { contracts.put("other", timelineChannel("other")); contracts.put("increment", operation("owner", integerPattern())); Node workflow = sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())); workflow.properties("channel", new Node().value("other")); contracts.put("incrementImpl", workflow); Node initialized = initializedDocument(fixture, document(fixture.repository, 0, contracts)); @@ -149,7 +147,7 @@ void integerRequestPatternAcceptsIntegerAndRejectsText() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())))); Node afterInteger = processOperationRequest(fixture, initialized, "owner", 1, "increment", new Node().value(7)); Node afterText = processOperationRequest(fixture, afterInteger, "owner", 2, "increment", new Node().value("7")); @@ -164,8 +162,7 @@ void objectRequestPatternAcceptsRequiredNestedProperty() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", objectAmountPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request.amount + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineAmountIncrementValue())))); Node processed = processOperationRequest(fixture, initialized, "owner", 1, "increment", new Node().properties("amount", new Node().value(7))); @@ -179,8 +176,7 @@ void objectRequestPatternRejectsMissingRequiredNestedProperty() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", objectAmountPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request.amount + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineAmountIncrementValue())))); Node processed = processOperationRequest(fixture, initialized, "owner", 1, "increment", new Node().properties("ignored", new Node().value(7))); @@ -194,8 +190,7 @@ void requestPatternIgnoresIrrelevantLargePayloadBranches() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", objectAmountPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request.amount + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineAmountIncrementValue())))); Node irrelevant = new Node().properties("nested", largePayloadBranch()); Node request = new Node() .properties("amount", new Node().value(7)) @@ -214,7 +209,7 @@ void pinnedMatchingInitialDocumentRunsWhenNewerVersionIsNotAllowed() { Node original = timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}")))); + updateDocumentStep("replace", "/counter", timelineIncrementValue()))); Node initialized = initializedDocument(fixture, original); Node pinned = new Node().blueId((String) initialized.get("/contracts/initialized/documentId")); @@ -232,9 +227,9 @@ void pinnedStaleDocumentDoesNotRunWhenNewerVersionIsNotAllowed() { Node original = timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}")))); + updateDocumentStep("replace", "/counter", timelineIncrementValue()))); Node initialized = initializedDocument(fixture, original); - Node stale = new Node().blueId("stale-document-blue-id"); + Node stale = new Node().blueId("2vz831ZwzhpUefTb5XkodBRANKpFMbj1F4CN33kf38Hw"); Node processed = processOperationRequest(fixture, initialized, "owner", 1, operationRequestEventNode("increment", new Node().value(7)) @@ -244,44 +239,15 @@ void pinnedStaleDocumentDoesNotRunWhenNewerVersionIsNotAllowed() { assertCounter(processed, 0); } - @Test - void pinnedFullDocumentBodyIsComparedToInitializedDocumentIdWhenNewerVersionIsNotAllowed() { - Fixture fixture = configuredFixture(); - Node matchingOriginal = timelineCounterDocument(fixture.repository, - operation("owner", integerPattern()), - sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}")))); - Node initialized = initializedDocument(fixture, matchingOriginal); - Node matchingPinnedBody = fixture.blue.resolveToSnapshot(matchingOriginal).canonicalRoot(); - Node differentPinnedBody = fixture.blue.resolveToSnapshot(timelineCounterDocument(fixture.repository, - 99, - operation("owner", integerPattern()), - sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))))) - .canonicalRoot(); - - Node afterMatchingBody = processOperationRequest(fixture, initialized, "owner", 1, - operationRequestEventNode("increment", new Node().value(7)) - .properties("allowNewerVersion", new Node().value(false)) - .properties("document", matchingPinnedBody)); - Node afterDifferentBody = processOperationRequest(fixture, initialized, "owner", 2, - operationRequestEventNode("increment", new Node().value(7)) - .properties("allowNewerVersion", new Node().value(false)) - .properties("document", differentPinnedBody)); - - assertCounter(afterMatchingBody, 7); - assertCounter(afterDifferentBody, 0); - } - @Test void allowNewerVersionTrueRunsWithStalePinnedDocument() { Fixture fixture = configuredFixture(); Node original = timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}")))); + updateDocumentStep("replace", "/counter", timelineIncrementValue()))); Node initialized = initializedDocument(fixture, original); - Node stale = new Node().blueId("stale-document-blue-id"); + Node stale = new Node().blueId("2vz831ZwzhpUefTb5XkodBRANKpFMbj1F4CN33kf38Hw"); Node processed = processOperationRequest(fixture, initialized, "owner", 1, operationRequestEventNode("increment", new Node().value(7)) @@ -297,7 +263,7 @@ void missingPinnedDocumentRunsWhenNewerVersionIsNotAllowed() { Node initialized = initializedDocument(fixture, timelineCounterDocument(fixture.repository, operation("owner", integerPattern()), sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", new Node().value("${event.message.request + document('/counter')}"))))); + updateDocumentStep("replace", "/counter", timelineIncrementValue())))); Node processed = processOperationRequest(fixture, initialized, "owner", 1, operationRequestEventNode("increment", new Node().value(7)) @@ -328,12 +294,12 @@ private static Node timelineChannel(String timelineId) { } private static Node triggeredChannel() { - return new Node().type("Core/Triggered Event Channel"); + return new Node().type("Triggered Event Channel"); } private static Node operation(String channel, Node requestPattern) { Node operation = new Node() - .type("Conversation/Operation") + .type("Coordination/Operation") .properties("request", requestPattern); if (channel != null) { operation.properties("channel", new Node().value(channel)); @@ -354,36 +320,62 @@ private static Node objectAmountPattern() { private static Node sequentialWorkflowOperation(String operation, Node... steps) { return new Node() - .type("Conversation/Sequential Workflow Operation") + .type("Coordination/Sequential Workflow Operation") .properties("operation", new Node().value(operation)) .properties("steps", new Node().items(steps)); } private static Node directWorkflow(String channel, Node... steps) { return new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value(channel)) .properties("steps", new Node().items(steps)); } private static Node updateDocumentStep(String op, String path, Node value) { return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendChange", new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value)), + new Node().properties("$return", new Node().value(true)))); + } + + private static Node directOperationIncrementValue() { + return bexAdd(bexBinding("event", "/request"), bexDocument("/counter")); + } + + private static Node timelineIncrementValue() { + return bexAdd(bexBinding("event", "/message/request"), bexDocument("/counter")); + } + + private static Node timelineAmountIncrementValue() { + return bexAdd(bexBinding("event", "/message/request/amount"), bexDocument("/counter")); + } + + private static Node bexAdd(Node... values) { + return new Node().properties("$add", new Node().items(values)); + } + + private static Node bexDocument(String path) { + return new Node().properties("$document", new Node().value(path)); + } + + private static Node bexBinding(String name, String path) { + return new Node().properties("$binding", new Node().value(name + path)); } private static Node triggerEventStep(Node event) { return new Node() - .type("Conversation/Trigger Event") + .type("Coordination/Trigger Event") .properties("event", event); } private static Node operationRequestEventNode(String operation, Node request) { return new Node() - .type("Conversation/Operation Request") + .type("Coordination/Operation Request") .properties("operation", new Node().value(operation)) .properties("request", request); } @@ -444,7 +436,7 @@ private static Node operationRequestTimelineEntry(Fixture fixture, String sourceValue) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value(timelineId))) .properties("timestamp", new Node().value(timestamp)) @@ -453,20 +445,20 @@ private static Node operationRequestTimelineEntry(Fixture fixture, event.properties("source", new Node() .properties("value", new Node().value(sourceValue))); } - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node chatTimelineEntry(Fixture fixture, String timelineId, int timestamp) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value(timelineId))) .properties("timestamp", new Node().value(timestamp)) .properties("message", new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value("run"))); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node largePayloadBranch() { @@ -494,9 +486,8 @@ private static Node initializedDocument(Fixture fixture, Node document) { private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } diff --git a/src/test/java/blue/contract/processor/conversation/RepositoryStyleCounterDocumentTest.java b/src/test/java/blue/coordination/processor/RepositoryStyleCounterDocumentTest.java similarity index 68% rename from src/test/java/blue/contract/processor/conversation/RepositoryStyleCounterDocumentTest.java rename to src/test/java/blue/coordination/processor/RepositoryStyleCounterDocumentTest.java index fd6ab8d..67943d1 100644 --- a/src/test/java/blue/contract/processor/conversation/RepositoryStyleCounterDocumentTest.java +++ b/src/test/java/blue/coordination/processor/RepositoryStyleCounterDocumentTest.java @@ -1,11 +1,11 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; +import blue.coordination.processor.CoordinationProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.OperationRequest; +import blue.repo.coordination.OperationRequest; import java.math.BigInteger; import org.junit.jupiter.api.Test; @@ -61,9 +61,7 @@ void richCounterDocumentInitializesAndProcessesIncrementOperation() { resolved.getAsText("/contracts/checkpoint/lastEvents/ownerChannel/message/operation")); assertEquals(BigInteger.valueOf(5), resolved.get("/contracts/checkpoint/lastEvents/ownerChannel/message/request")); - Object signature = resolved.get("/contracts/checkpoint/lastSignatures/ownerChannel"); - assertTrue(signature instanceof String); - assertFalse(((String) signature).isEmpty()); + assertNotNull(resolved.get("/contracts/checkpoint/lastEvents/ownerChannel")); } private static Node richCounterDocument(Fixture fixture) { @@ -78,8 +76,7 @@ private static String richCounterDocumentYaml() { "counter: 0", "contracts:", " ownerChannel:", - " type:", - " blueId: " + TestTimelineProvider.SIMPLE_TIMELINE_CHANNEL_BLUE_ID, + " type: Coordination/Timeline Channel", " order:", " description: Deterministic sort key within a scope; missing == 0.", " type: Integer", @@ -91,7 +88,7 @@ private static String richCounterDocumentYaml() { " value: " + TIMELINE_ID, " increment:", " description: Increment the counter by the given number", - " type: Conversation/Operation", + " type: Coordination/Operation", " order:", " description: Deterministic sort key within a scope; missing == 0.", " type: Integer", @@ -103,7 +100,7 @@ private static String richCounterDocumentYaml() { " description: Represents a value by which counter will be incremented", " type: Integer", " incrementImpl:", - " type: Conversation/Sequential Workflow Operation", + " type: Coordination/Sequential Workflow Operation", " order:", " description: Deterministic sort key within a scope; missing == 0.", " type: Integer", @@ -115,36 +112,45 @@ private static String richCounterDocumentYaml() { " steps:", " description: Ordered list of steps to execute (positional semantics).", " type: List", - " itemType: Conversation/Sequential Workflow Step", + " itemType: Coordination/Sequential Workflow Step", " items:", - " - type: Conversation/Update Document", - " changeset:", - " - op: replace", - " path: /counter", - " val: ${event.message.request + document('/counter')}", + " - name: ApplyIncrement", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /counter", + " val:", + " $add:", + " - $document: /counter", + " - $binding:", + " name: event", + " path: /message/request", + " - $return: {}", " - name: CreateMessageEvent", - " type: Conversation/JavaScript Code", - " code:", - " description: JavaScript source to execute for this step.", - " type: Text", - " value: |-", - " const message = `Counter was incremented by ${event.message.request} and is now ${document('/counter')}`;", - "", - " return {", - " events: [", - " {", - " type: \"Conversation/Chat Message\",", - " message: message,", - " },", - " ],", - " };", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " $merge:", + " - type: Coordination/Chat Message", + " - message:", + " $concat:", + " - Counter was incremented by", + " - \" \"", + " - $binding:", + " name: event", + " path: /message/request", + " - \" and is now \"", + " - $text:", + " $document: /counter", + " - $return: {}", " operation:", " description: The name of the Operation this handler implements. Must reference an Operation defined in the same scope.", " type: Text", " value: increment", " decrement:", " description: Decrement the counter by the given number", - " type: Conversation/Operation", + " type: Coordination/Operation", " order:", " description: Deterministic sort key within a scope; missing == 0.", " type: Integer", @@ -156,7 +162,7 @@ private static String richCounterDocumentYaml() { " description: Value to subtract", " type: Integer", " decrementImpl:", - " type: Conversation/Sequential Workflow Operation", + " type: Coordination/Sequential Workflow Operation", " order:", " description: Deterministic sort key within a scope; missing == 0.", " type: Integer", @@ -168,29 +174,38 @@ private static String richCounterDocumentYaml() { " steps:", " description: Ordered list of steps to execute (positional semantics).", " type: List", - " itemType: Conversation/Sequential Workflow Step", + " itemType: Coordination/Sequential Workflow Step", " items:", - " - type: Conversation/Update Document", - " changeset:", - " - op: replace", - " path: /counter", - " val: ${document('/counter') - event.message.request}", + " - name: ApplyDecrement", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /counter", + " val:", + " $subtract:", + " - $document: /counter", + " - $binding:", + " name: event", + " path: /message/request", + " - $return: {}", " - name: CreateMessageEvent", - " type: Conversation/JavaScript Code", - " code:", - " description: JavaScript source to execute for this step.", - " type: Text", - " value: |-", - " const message = `Counter was decremented by ${event.message.request} and is now ${document('/counter')}`;", - "", - " return {", - " events: [", - " {", - " type: \"Conversation/Chat Message\",", - " message: message,", - " },", - " ],", - " };", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " $merge:", + " - type: Coordination/Chat Message", + " - message:", + " $concat:", + " - Counter was decremented by", + " - \" \"", + " - $binding:", + " name: event", + " path: /message/request", + " - \" and is now \"", + " - $text:", + " $document: /counter", + " - $return: {}", " operation:", " description: The name of the Operation this handler implements. Must reference an Operation defined in the same scope.", " type: Text", @@ -208,7 +223,13 @@ private static Node operationRequest(String operation, int request) { } private static Node property(Node node, String key) { - if (node == null || node.getProperties() == null) { + if (node == null) { + return null; + } + if ("contracts".equals(key)) { + return node.getContracts(); + } + if (node.getProperties() == null) { return null; } return node.getProperties().get(key); @@ -216,9 +237,8 @@ private static Node property(Node node, String key) { private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } diff --git a/src/test/java/blue/coordination/processor/RepositoryTypeAliasPreprocessorTest.java b/src/test/java/blue/coordination/processor/RepositoryTypeAliasPreprocessorTest.java new file mode 100644 index 0000000..ecc9763 --- /dev/null +++ b/src/test/java/blue/coordination/processor/RepositoryTypeAliasPreprocessorTest.java @@ -0,0 +1,37 @@ +package blue.coordination.processor; + +import blue.language.model.Node; +import blue.repo.BlueRepository; +import blue.repo.common.CryptoEd25519Verify; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RepositoryTypeAliasPreprocessorTest { + @Test + void resolvesCorrectRepositoryQualifiedIntrinsicAlias() { + Node node = intrinsicType("Common/Crypto Ed25519 Verify"); + + Node resolved = new RepositoryTypeAliasPreprocessor(BlueRepository.v1_3_0()).preprocess(node); + + assertEquals(CryptoEd25519Verify.blueId(), + resolved.getProperties().get("$intrinsic").getType().getBlueId()); + } + + @Test + void doesNotResolveOldDoubleCommonAlias() { + Node node = intrinsicType("Common/Common/Crypto Ed25519 Verify"); + + Node resolved = new RepositoryTypeAliasPreprocessor(BlueRepository.v1_3_0()).preprocess(node); + + assertEquals("Common/Common/Crypto Ed25519 Verify", + resolved.getProperties().get("$intrinsic").getType().getBlueId()); + } + + private static Node intrinsicType(String blueId) { + Node intrinsic = new Node() + .type(new Node().blueId(blueId)) + .properties("message", new Node().value("test")); + return new Node().properties("$intrinsic", intrinsic); + } +} diff --git a/src/test/java/blue/contract/processor/conversation/CoreRuntimeChannelsTest.java b/src/test/java/blue/coordination/processor/RuntimeChannelsTest.java similarity index 79% rename from src/test/java/blue/contract/processor/conversation/CoreRuntimeChannelsTest.java rename to src/test/java/blue/coordination/processor/RuntimeChannelsTest.java index cf25757..db7c4be 100644 --- a/src/test/java/blue/contract/processor/conversation/CoreRuntimeChannelsTest.java +++ b/src/test/java/blue/coordination/processor/RuntimeChannelsTest.java @@ -1,9 +1,9 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.registry.RuntimeBlueIds; import blue.repo.BlueRepository; import java.math.BigInteger; import java.util.LinkedHashMap; @@ -18,17 +18,17 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class CoreRuntimeChannelsTest { +class RuntimeChannelsTest { @Test - void realCoreDocumentUpdateChannelReceivesUpdateEvents() { + void runtimeDocumentUpdateChannelReceivesUpdateEvents() { Fixture fixture = configuredFixture(); Map contracts = ownerChannelContracts(); contracts.put("updates", documentUpdateChannel("/counter")); contracts.put("writer", directWorkflow("owner", updateDocumentStep("replace", "/counter", new Node().value(5)))); contracts.put("observer", directWorkflowMatching("updates", - new Node().type("Core/Document Update"), - triggerEventStep(chatMessageEvent("updated ${event.path} from ${event.before} to ${event.after}")))); + new Node().type("Document Update"), + computeAppendChatMessageStep(documentUpdateMessage()))); Node document = initializedDocument(fixture, document(fixture.repository, 0, contracts)); DocumentProcessingResult result = processChat(fixture, document, 1); @@ -38,17 +38,17 @@ void realCoreDocumentUpdateChannelReceivesUpdateEvents() { } @Test - void documentUpdateChannelPathFilteringUsesRepositoryCoreTypes() { + void documentUpdateChannelPathFilteringUsesRepositoryTypes() { Fixture fixture = configuredFixture(); Map contracts = ownerChannelContracts(); contracts.put("counterUpdates", documentUpdateChannel("/counter")); contracts.put("nameUpdates", documentUpdateChannel("/name")); contracts.put("writer", directWorkflow("owner", updateDocumentStep("replace", "/counter", new Node().value(5)))); contracts.put("counterObserver", directWorkflowMatching("counterUpdates", - new Node().type("Core/Document Update"), + new Node().type("Document Update"), triggerEventStep(chatMessageEvent("counter updated")))); contracts.put("nameObserver", directWorkflowMatching("nameUpdates", - new Node().type("Core/Document Update"), + new Node().type("Document Update"), triggerEventStep(chatMessageEvent("name updated")))); Node document = initializedDocument(fixture, document(fixture.repository, 0, contracts)); @@ -66,8 +66,8 @@ void nestedUpdatesPropagateToParentWatchers() { contracts.put("writer", directWorkflow("owner", updateDocumentStep("replace", "/profile/name", new Node().value("Ada")))); contracts.put("observer", directWorkflowMatching("profileUpdates", - new Node().type("Core/Document Update"), - triggerEventStep(chatMessageEvent("updated ${event.path} from ${event.before} to ${event.after}")))); + new Node().type("Document Update"), + computeAppendChatMessageStep(documentUpdateMessage()))); Node document = document(fixture.repository, 0, contracts) .properties("profile", new Node() .properties("name", new Node().value("Grace"))); @@ -92,7 +92,7 @@ void updateEventCanBeMatchedMoreSpecifically() { updateDocumentStep("add", "/other", new Node().value(9)))); contracts.put("observer", directWorkflowMatching("allUpdates", new Node() - .type("Core/Document Update") + .type("Document Update") .properties("path", new Node().value("/counter")) .properties("op", new Node().value("replace")), triggerEventStep(chatMessageEvent("specific replace")))); @@ -146,7 +146,7 @@ void replacingEmbeddedNodeCutsOffChildScopeWithinRun() { rootContracts.put("embedded", processEmbedded("/child")); rootContracts.put("childUpdates", documentUpdateChannel("/child/marker")); rootContracts.put("cutChild", directWorkflowMatching("childUpdates", - new Node().type("Core/Document Update"), + new Node().type("Document Update"), updateDocumentStep("replace", "/child", new Node() .name("Replacement Child") .properties("counter", new Node().value(0))))); @@ -187,7 +187,7 @@ void duplicateExternalEventsAreSkippedWithRealRepositoryChannelCheckpointShape() Fixture fixture = configuredFixture(); Map contracts = ownerChannelContracts(); contracts.put("writer", directWorkflow("owner", - updateDocumentStep("replace", "/counter", new Node().value("${document('/counter') + 1}")))); + computePatchStep("replace", "/counter", bexAdd(bexDocument("/counter"), new Node().value(1))))); Node initialized = initializedDocument(fixture, document(fixture.repository, 0, contracts)); Node event = chatTimelineEntry(fixture, 1); @@ -198,14 +198,13 @@ void duplicateExternalEventsAreSkippedWithRealRepositoryChannelCheckpointShape() Node checkpoint = nodeAt(afterSecond, "/contracts/checkpoint"); assertNotNull(checkpoint); assertNotNull(nodeAt(checkpoint, "/lastEvents/owner")); - assertNotNull(checkpoint.get("/lastSignatures/owner")); } @Test void checkpointDeclaredUnderWrongKeyFails() { Fixture fixture = configuredFixture(); Map contracts = ownerChannelContracts(); - contracts.put("wrongCheckpoint", new Node().type("Core/Channel Event Checkpoint")); + contracts.put("wrongCheckpoint", new Node().type("Channel Event Checkpoint")); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> fixture.blue.initializeDocument(fixture.blue.preprocess(document(fixture.repository, 0, contracts)))); @@ -218,12 +217,11 @@ void multipleCheckpointMarkersInOneScopeFail() { Fixture fixture = configuredFixture(); Map contracts = ownerChannelContracts(); Node initialized = initializedDocument(fixture, document(fixture.repository, 0, contracts)); - initialized.getProperties().get("contracts").properties("checkpoint", new Node() - .type("Core/Channel Event Checkpoint") - .properties("lastEvents", new Node().properties(new LinkedHashMap())) - .properties("lastSignatures", new Node().properties(new LinkedHashMap()))); - initialized.getProperties().get("contracts").properties("extraCheckpoint", new Node() - .type("Core/Channel Event Checkpoint")); + initialized.getContracts().properties("checkpoint", new Node() + .type(new Node().blueId(RuntimeBlueIds.CHANNEL_EVENT_CHECKPOINT)) + .properties("lastEvents", new Node().properties(new LinkedHashMap()))); + initialized.getContracts().properties("extraCheckpoint", new Node() + .type(new Node().blueId(RuntimeBlueIds.CHANNEL_EVENT_CHECKPOINT))); assertThrows(RuntimeException.class, () -> fixture.blue.processDocument(fixture.blue.preprocess(initialized), chatTimelineEntry(fixture, 1))); @@ -233,8 +231,8 @@ private static Node embeddedOperationDocument(BlueRepository repository) { Map childContracts = ownerChannelContracts(); childContracts.put("increment", operation("owner")); childContracts.put("incrementImpl", sequentialWorkflowOperation("increment", - updateDocumentStep("replace", "/counter", - new Node().value("${event.message.request + document('/counter')}")))); + computePatchStep("replace", "/counter", + bexAdd(bexBinding("event", "/message/request"), bexDocument("/counter"))))); Map rootContracts = new LinkedHashMap(); rootContracts.put("embedded", processEmbedded("/child")); @@ -251,21 +249,21 @@ private static Node embeddedBridgeDocument(BlueRepository repository, String chi Map rootContracts = ownerChannelContracts(); rootContracts.put("embedded", new Node() - .type("Core/Process Embedded") + .type("Process Embedded") .properties("paths", new Node().items( new Node().value("/child"), new Node().value("/otherChild")))); rootContracts.put("embeddedEvents", new Node() - .type("Core/Embedded Node Channel") + .type("Embedded Node Channel") .properties("childPath", new Node().value(childPath))); rootContracts.put("childObserver", directWorkflowMatching("embeddedEvents", new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value("child emitted")), triggerEventStep(chatMessageEvent("parent saw child emitted")))); rootContracts.put("otherChildObserver", directWorkflowMatching("embeddedEvents", new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value("other child emitted")), triggerEventStep(chatMessageEvent("parent saw other child emitted")))); return document(repository, 0, rootContracts) @@ -281,33 +279,33 @@ private static Map ownerChannelContracts() { private static Node documentUpdateChannel(String path) { return new Node() - .type("Core/Document Update Channel") + .type("Document Update Channel") .properties("path", new Node().value(path)); } private static Node processEmbedded(String path) { return new Node() - .type("Core/Process Embedded") + .type("Process Embedded") .properties("paths", new Node().items(new Node().value(path))); } private static Node operation(String channel) { return new Node() - .type("Conversation/Operation") + .type("Coordination/Operation") .properties("channel", new Node().value(channel)) .properties("request", new Node().type("Integer")); } private static Node sequentialWorkflowOperation(String operation, Node... steps) { return new Node() - .type("Conversation/Sequential Workflow Operation") + .type("Coordination/Sequential Workflow Operation") .properties("operation", new Node().value(operation)) .properties("steps", new Node().items(steps)); } private static Node directWorkflow(String channel, Node... steps) { Node workflow = new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value(channel)) .properties("steps", new Node().items(steps)); return workflow; @@ -315,7 +313,7 @@ private static Node directWorkflow(String channel, Node... steps) { private static Node directWorkflowMatching(String channel, Node event, Node... steps) { Node workflow = new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value(channel)) .properties("steps", new Node().items(steps)); workflow.properties("event", event); @@ -324,7 +322,7 @@ private static Node directWorkflowMatching(String channel, Node event, Node... s private static Node updateDocumentStep(String op, String path, Node value) { return new Node() - .type("Conversation/Update Document") + .type("Coordination/Update Document") .properties("changeset", new Node().items(new Node() .properties("op", new Node().value(op)) .properties("path", new Node().value(path)) @@ -333,16 +331,71 @@ private static Node updateDocumentStep(String op, String path, Node value) { private static Node triggerEventStep(Node event) { return new Node() - .type("Conversation/Trigger Event") + .type("Coordination/Trigger Event") .properties("event", event); } + private static Node computePatchStep(String op, String path, Node value) { + return new Node() + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendChange", new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value)), + new Node().properties("$return", new Node().value(true)))); + } + + private static Node computeAppendChatMessageStep(Node message) { + return new Node() + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendEvent", chatMessageBexEvent(message)), + new Node().properties("$return", new Node().value(true)))); + } + private static Node chatMessageEvent(String message) { return new Node() - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value(message)); } + private static Node chatMessageBexEvent(Node message) { + return new Node().properties("$merge", new Node().items( + new Node().properties("type", new Node().value("Coordination/Chat Message")), + new Node().properties("message", message))); + } + + private static Node documentUpdateMessage() { + return bexConcat( + new Node().value("updated "), + bexBinding("event", "/path"), + new Node().value(" from "), + bexText(bexBinding("event", "/before")), + new Node().value(" to "), + bexText(bexBinding("event", "/after"))); + } + + private static Node bexAdd(Node... values) { + return new Node().properties("$add", new Node().items(values)); + } + + private static Node bexConcat(Node... values) { + return new Node().properties("$concat", new Node().items(values)); + } + + private static Node bexText(Node value) { + return new Node().properties("$text", value); + } + + private static Node bexDocument(String path) { + return new Node().properties("$document", new Node().value(path)); + } + + private static Node bexBinding(String name, String path) { + return new Node().properties("$binding", new Node().value(name + path)); + } + private static Node childDocument(int counter, Map contracts) { Node child = new Node() .name("Child") @@ -356,7 +409,7 @@ private static Node childDocument(int counter, Map contracts) { private static Node document(BlueRepository repository, int counter, Map contracts) { return new Node() .blue(repository.typeAliasBlue()) - .name("Core Runtime Test") + .name("Runtime Channel Test") .properties("counter", new Node().value(counter)) .properties("contracts", new Node().properties(contracts)); } @@ -372,12 +425,12 @@ private static DocumentProcessingResult processChat(Fixture fixture, Node docume private static Node chatTimelineEntry(Fixture fixture, int timestamp) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value("owner"))) .properties("timestamp", new Node().value(timestamp)) .properties("message", chatMessageEvent("run")); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node operationRequestEvent(Fixture fixture, @@ -386,15 +439,15 @@ private static Node operationRequestEvent(Fixture fixture, Node request) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value("owner"))) .properties("timestamp", new Node().value(timestamp)) .properties("message", new Node() - .type("Conversation/Operation Request") + .type("Coordination/Operation Request") .properties("operation", new Node().value(operation)) .properties("request", request)); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node nodeAt(Node node, String pointer) { @@ -408,9 +461,8 @@ private static Node nodeAt(Node node, String pointer) { private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } diff --git a/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java b/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java new file mode 100644 index 0000000..6cc54aa --- /dev/null +++ b/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java @@ -0,0 +1,712 @@ +package blue.coordination.processor; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; +import blue.coordination.processor.workflow.StepExecutionContext; +import blue.coordination.processor.workflow.UpdateDocumentStepExecutor; +import blue.coordination.processor.workflow.WorkflowStepExecutor; +import blue.coordination.processor.workflow.WorkflowStepResult; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import blue.repo.BlueRepository; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TriggerEvent; +import blue.repo.coordination.UpdateDocument; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SequentialWorkflowExecutionTest { + + @Test + void sequentialWorkflowOperationDerivesAndMatchesOperationRequest() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); + + assertCounter(processed, 7); + } + + @Test + void wrongOperationDoesNotRun() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, false)); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "decrement", 7); + + assertCounter(processed, 0); + } + + @Test + void wrongRequestTypeDoesNotRun() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); + + Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value("text")); + Node processed = fixture.blue.processDocument(document, event).document(); + + assertCounter(processed, 0); + } + + @Test + void duplicateRequestDoesNotRunTwice() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); + Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value(7)); + + Node afterFirst = fixture.blue.processDocument(document, event).document(); + Node afterSecond = fixture.blue.processDocument(afterFirst, event).document(); + + assertCounter(afterSecond, 7); + } + + @Test + void newerRequestRunsAfterPreviousRequest() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 0, true)); + Node afterFirst = processOperationRequest(fixture, document, "owner", 1, "increment", 7); + + Node afterSecond = processOperationRequest(fixture, afterFirst, "owner", 2, "increment", 5); + + assertCounter(afterSecond, 12); + } + + @Test + void decrementComputeWorks() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterDocument(fixture.repository, 10, true)); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "decrement", 3); + + assertCounter(processed, 7); + } + + @Test + void multipleComputeStepsSeePreviousStepState() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, doubleIncrementDocument(fixture.repository)); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 2); + + assertCounter(processed, 4); + } + + @Test + void directSequentialWorkflowExecutesUpdateDocument() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository)); + Node event = chatTimelineEntry(fixture, "owner", 1, "run"); + + Node processed = fixture.blue.processDocument(document, event).document(); + + assertCounter(processed, 5); + } + + @Test + void unsupportedStepFailsExplicitly() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, unsupportedStepDocument(fixture.repository)); + Node event = chatTimelineEntry(fixture, "owner", 1, "run"); + + DocumentProcessingResult result = fixture.blue.processDocument(document, event); + + assertRuntimeFatal(result, "Unsupported sequential workflow step"); + } + + @Test + void coordinationProcessorOptionsInjectsSequentialWorkflowRunner() { + WorkflowStepExecutor injectedExecutor = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof UpdateDocument; + } + + @Override + public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { + context.processorContext().throwFatal("injected runner"); + return WorkflowStepResult.none(); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList( + injectedExecutor)); + CoordinationProcessorOptions options = CoordinationProcessorOptions.builder() + .sequentialWorkflowRunner(runner) + .build(); + Fixture fixture = configuredCoordinationFixture(options); + Node document = initializedDocument(fixture, staticUpdateDocument(fixture.repository, + 0, + new Node().value(1))); + + DocumentProcessingResult result = processOperationRequestResult(fixture, + document, + "owner", + 1, + "increment", + new Node().value(7)); + + assertRuntimeFatal(result, "injected runner"); + } + + @Test + void literalUpdateValuesPassThrough() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, staticUpdateDocument(fixture.repository, + 0, + new Node().properties("nested", new Node().value(true)))); + + Node processed = processOperationRequest(fixture, document, "owner", 1, "increment", 7); + + assertEquals(Boolean.TRUE, processed.get("/counter/nested")); + } + + @Test + void stepResultsAreCollected() { + final AtomicReference> seenResults = new AtomicReference>(); + WorkflowStepExecutor first = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof UpdateDocument; + } + + @Override + public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { + return WorkflowStepResult.value("a"); + } + }; + WorkflowStepExecutor second = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + seenResults.set(context.stepResults()); + return WorkflowStepResult.value("b"); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList(first, second)); + Fixture fixture = configuredFixture(null, runner); + Node document = initializedDocument(fixture, stepResultsDocument(fixture.repository)); + Node event = chatTimelineEntry(fixture, "owner", 1, "run"); + + fixture.blue.processDocument(document, event); + + assertEquals(1, seenResults.get().size()); + assertEquals("a", seenResults.get().get("Step1")); + } + + @Test + void patchPathResolvesAgainstEmbeddedScope() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, embeddedScopeDocument(fixture.repository)); + Node event = operationRequestEvent(fixture, "owner", 1, "increment", new Node().value(7)); + + Node processed = fixture.blue.processDocument(document, event).document(); + + assertEquals(BigInteger.valueOf(100), processed.get("/counter")); + assertEquals(BigInteger.valueOf(7), processed.get("/child/counter")); + } + + @Test + void computeEventStepSeesUpdatedDocument() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + updateDocumentStep("replace", "/counter", new Node().value(5)), + computeAppendChatMessageStep(bexConcat(new Node().value("counter is "), bexText(bexDocument("/counter")))))); + + DocumentProcessingResult result = processChat(fixture, document, "owner", 1, "run"); + + assertCounter(result.document(), 5); + assertTriggeredChatMessage(result, "counter is 5"); + } + + @Test + void triggerEventStepEmitsEvent() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + triggerEventStep("Workflow finished"))); + + DocumentProcessingResult result = processChat(fixture, document, "owner", 1, "run"); + + assertTriggeredChatMessage(result, "Workflow finished"); + } + + @Test + void fullCounterWorkflowEmitsChatMessageWithTriggerEvent() { + Fixture fixture = configuredFixture(); + Node document = initializedDocument(fixture, counterWorkflowDocument(fixture.repository, + 0, + computeReplaceCounterStep(incrementValue()), + computeAppendChatMessageStep(bexConcat( + new Node().value("Counter was incremented by "), + bexBinding("event", "/message/request"), + new Node().value(" and is now "), + bexText(bexDocument("/counter")))))); + + DocumentProcessingResult result = processOperationRequestResult(fixture, + document, + "owner", + 1, + "increment", + new Node().value(7)); + + assertCounter(result.document(), 7); + assertTriggeredChatMessage(result, "Counter was incremented by 7 and is now 7"); + } + + @Test + void updateDocumentDoesNotCreateStepResult() { + final AtomicReference seenResultCount = new AtomicReference(); + WorkflowStepExecutor inspectStep = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + seenResultCount.set(context.stepResults().size()); + return WorkflowStepResult.none(); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList( + new UpdateDocumentStepExecutor(), + inspectStep)); + Fixture fixture = configuredFixture(null, runner); + Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + updateDocumentStep("replace", "/counter", new Node().value(3)), + triggerEventStep("ignored").name("Inspect"))); + + Node processed = processChat(fixture, document, "owner", 1, "run").document(); + + assertCounter(processed, 3); + assertEquals(Integer.valueOf(0), seenResultCount.get()); + } + + @Test + void nullStepResultIsPreserved() { + final AtomicReference sawNullResult = new AtomicReference(); + final AtomicReference firstCall = new AtomicReference(Boolean.TRUE); + WorkflowStepExecutor executor = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + if (Boolean.TRUE.equals(firstCall.get())) { + firstCall.set(Boolean.FALSE); + return WorkflowStepResult.value(null); + } + sawNullResult.set(context.stepResults().containsKey("MaybeNull") + && context.stepResults().get("MaybeNull") == null); + return WorkflowStepResult.none(); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList( + executor)); + Fixture fixture = configuredFixture(null, runner); + Node document = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + triggerEventStep("ignored").name("MaybeNull"), + triggerEventStep("inspect").name("Inspect"))); + + Node processed = processChat(fixture, document, "owner", 1, "run").document(); + + assertCounter(processed, 0); + assertEquals(Boolean.TRUE, sawNullResult.get()); + } + + private static Node processOperationRequest(Fixture fixture, + Node document, + String timelineId, + int timestamp, + String operation, + int request) { + return processOperationRequestResult(fixture, + document, + timelineId, + timestamp, + operation, + new Node().value(request)).document(); + } + + private static DocumentProcessingResult processOperationRequestResult(Fixture fixture, + Node document, + String timelineId, + int timestamp, + String operation, + Node request) { + Node event = operationRequestEvent(fixture, timelineId, timestamp, operation, request); + return fixture.blue.processDocument(document, event); + } + + private static DocumentProcessingResult processChat(Fixture fixture, + Node document, + String timelineId, + int timestamp, + String message) { + Node event = chatTimelineEntry(fixture, timelineId, timestamp, message); + return fixture.blue.processDocument(document, event); + } + + private static Node counterDocument(BlueRepository repository, int counter, boolean includeDecrement) { + Map contracts = baseOperationContracts(); + contracts.put("increment", operation("ownerChannel")); + contracts.put("incrementImpl", sequentialWorkflowOperation("increment", + computeReplaceCounterStep(incrementValue()))); + if (includeDecrement) { + contracts.put("decrement", operation("ownerChannel")); + contracts.put("decrementImpl", sequentialWorkflowOperation("decrement", + computeReplaceCounterStep(decrementValue()))); + } + return document(repository, counter, contracts); + } + + private static Node counterWorkflowDocument(BlueRepository repository, int counter, Node... steps) { + Map contracts = baseOperationContracts(); + contracts.put("increment", operation("ownerChannel")); + contracts.put("incrementImpl", sequentialWorkflowOperation("increment", steps)); + return document(repository, counter, contracts); + } + + private static Node staticUpdateDocument(BlueRepository repository, int counter, Node value) { + return staticUpdateDocument(repository, new Node().value(counter), value); + } + + private static Node staticUpdateDocument(BlueRepository repository, Node counter, Node value) { + Map contracts = baseOperationContracts(); + contracts.put("increment", operation("ownerChannel")); + contracts.put("incrementImpl", sequentialWorkflowOperation("increment", + updateDocumentStep("replace", "/counter", value))); + return document(repository, counter, contracts); + } + + private static Node doubleIncrementDocument(BlueRepository repository) { + Map contracts = baseOperationContracts(); + contracts.put("increment", operation("ownerChannel")); + contracts.put("incrementImpl", sequentialWorkflowOperation("increment", + computeReplaceCounterStep(incrementValue()), + computeReplaceCounterStep(incrementValue()))); + return document(repository, 0, contracts); + } + + private static Node directWorkflowDocument(BlueRepository repository) { + Map contracts = baseOperationContracts(); + contracts.put("direct", new Node() + .type("Coordination/Sequential Workflow") + .properties("channel", new Node().value("ownerChannel")) + .properties("event", new Node() + .properties("message", new Node() + .properties("message", new Node().value("run")))) + .properties("steps", new Node().items( + updateDocumentStep("replace", "/counter", new Node().value(5))))); + return document(repository, 0, contracts); + } + + private static Node directWorkflowStepsDocument(BlueRepository repository, int counter, Node... steps) { + return directWorkflowStepsDocument(repository, counter, null, steps); + } + + private static Node directWorkflowStepsDocument(BlueRepository repository, + int counter, + String description, + Node... steps) { + Map contracts = baseOperationContracts(); + Node workflow = new Node() + .type("Coordination/Sequential Workflow") + .properties("channel", new Node().value("ownerChannel")) + .properties("steps", new Node().items(steps)); + if (description != null) { + workflow.description(description); + } + contracts.put("direct", workflow); + return document(repository, counter, contracts); + } + + private static Node unsupportedStepDocument(BlueRepository repository) { + Map contracts = baseOperationContracts(); + contracts.put("direct", new Node() + .type("Coordination/Sequential Workflow") + .properties("channel", new Node().value("ownerChannel")) + .properties("steps", new Node().items(new Node() + .type("Coordination/Sequential Workflow Step")))); + return document(repository, 0, contracts); + } + + private static Node stepResultsDocument(BlueRepository repository) { + Map contracts = baseOperationContracts(); + contracts.put("direct", new Node() + .type("Coordination/Sequential Workflow") + .properties("channel", new Node().value("ownerChannel")) + .properties("steps", new Node().items( + updateDocumentStep("replace", "/counter", new Node().value(1)), + triggerEventStep("ignored")))); + return document(repository, 0, contracts); + } + + private static Node embeddedScopeDocument(BlueRepository repository) { + Map childContracts = baseOperationContracts(); + childContracts.put("increment", operation("ownerChannel")); + childContracts.put("incrementImpl", sequentialWorkflowOperation("increment", + computeReplaceCounterStep(incrementValue()))); + + Map rootContracts = new LinkedHashMap<>(); + rootContracts.put("embedded", new Node() + .type("Process Embedded") + .properties("paths", new Node().items(new Node().value("/child")))); + + return new Node() + .blue(repository.typeAliasBlue()) + .name("Root") + .properties("counter", new Node().value(100)) + .properties("child", new Node() + .name("Child") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(childContracts))) + .properties("contracts", new Node().properties(rootContracts)); + } + + private static Map baseOperationContracts() { + Map contracts = new LinkedHashMap<>(); + contracts.put("ownerChannel", TestTimelineProvider.channel("owner")); + return contracts; + } + + private static Node operation(String channel) { + return new Node() + .type("Coordination/Operation") + .properties("channel", new Node().value(channel)) + .properties("request", new Node().type("Integer")); + } + + private static Node sequentialWorkflowOperation(String operation, Node... steps) { + return new Node() + .type("Coordination/Sequential Workflow Operation") + .properties("operation", new Node().value(operation)) + .properties("steps", new Node().items(steps)); + } + + private static Node updateDocumentStep(String op, String path, Node value) { + return new Node() + .type("Coordination/Update Document") + .properties("changeset", new Node().items(new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value))); + } + + private static Node computeReplaceCounterStep(Node value) { + return new Node() + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendChange", new Node() + .properties("op", new Node().value("replace")) + .properties("path", new Node().value("/counter")) + .properties("val", value)), + new Node().properties("$return", new Node().value(true)))); + } + + private static Node incrementValue() { + return bexAdd(bexDocument("/counter"), bexBinding("event", "/message/request")); + } + + private static Node decrementValue() { + return bexSubtract(bexDocument("/counter"), bexBinding("event", "/message/request")); + } + + private static Node triggerEventStep(String message) { + return new Node() + .type("Coordination/Trigger Event") + .properties("event", new Node() + .type("Coordination/Chat Message") + .properties("message", new Node().value(message))); + } + + private static Node computeAppendChatMessageStep(Node message) { + return new Node() + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendEvent", new Node().properties("$merge", new Node().items( + new Node().properties("type", new Node().value("Coordination/Chat Message")), + new Node().properties("message", message)))), + new Node().properties("$return", new Node().value(true)))); + } + + private static Node bexAdd(Node... values) { + return new Node().properties("$add", new Node().items(values)); + } + + private static Node bexSubtract(Node... values) { + return new Node().properties("$subtract", new Node().items(values)); + } + + private static Node bexConcat(Node... values) { + return new Node().properties("$concat", new Node().items(values)); + } + + private static Node bexText(Node value) { + return new Node().properties("$text", value); + } + + private static Node bexDocument(String path) { + return new Node().properties("$document", new Node().value(path)); + } + + private static Node bexBinding(String name, String path) { + return new Node().properties("$binding", new Node().value(name + path)); + } + + private static Node document(BlueRepository repository, int counter, Map contracts) { + return document(repository, new Node().value(counter), contracts); + } + + private static Node document(BlueRepository repository, Node counter, Map contracts) { + return new Node() + .blue(repository.typeAliasBlue()) + .name("Counter") + .properties("counter", counter) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node operationRequestEvent(Fixture fixture, + String timelineId, + int timestamp, + String operation, + Node request) { + Node event = new Node() + .blue(fixture.repository.typeAliasBlue()) + .type("Coordination/Timeline Entry") + .properties("timeline", new Node() + .properties("timelineId", new Node().value(timelineId))) + .properties("timestamp", new Node().value(timestamp)) + .properties("message", new Node() + .type("Coordination/Operation Request") + .properties("operation", new Node().value(operation)) + .properties("request", request)); + return fixture.blue.preprocess(event).blue(null); + } + + private static Node chatTimelineEntry(Fixture fixture, String timelineId, int timestamp, String message) { + Node event = new Node() + .blue(fixture.repository.typeAliasBlue()) + .type("Coordination/Timeline Entry") + .properties("timeline", new Node() + .properties("timelineId", new Node().value(timelineId))) + .properties("timestamp", new Node().value(timestamp)) + .properties("message", new Node() + .type("Coordination/Chat Message") + .properties("message", new Node().value(message))); + return fixture.blue.preprocess(event).blue(null); + } + + private static Node initializedDocument(Fixture fixture, Node document) { + DocumentProcessingResult result = fixture.blue.initializeDocument(fixture.blue.preprocess(document)); + return result.document(); + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static Fixture configuredFixture(CoordinationProcessorOptions options) { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue, options); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static Fixture configuredCoordinationFixture(CoordinationProcessorOptions options) { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue, options); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static Fixture configuredFixture(SequentialWorkflowRunner operationRunner, + SequentialWorkflowRunner directRunner) { + Fixture fixture = configuredFixture(); + if (operationRunner != null) { + fixture.blue.registerContractProcessor(new SequentialWorkflowOperationProcessor(operationRunner)); + } + if (directRunner != null) { + fixture.blue.registerContractProcessor(new SequentialWorkflowProcessor(directRunner)); + } + return fixture; + } + + private static void assertCounter(Node document, int expected) { + assertEquals(BigInteger.valueOf(expected), document.get("/counter")); + } + + private static void assertRuntimeFatal(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null && result.failureReason().contains(expectedMessage), + result.failureReason()); + } + + private static void assertTriggeredChatMessage(DocumentProcessingResult result, String expectedMessage) { + for (Node event : result.triggeredEvents()) { + if (isChatMessage(event) + && expectedMessage.equals(event.get("/message"))) { + return; + } + } + throw new AssertionError("Expected triggered chat message: " + expectedMessage + + " in " + result.triggeredEvents()); + } + + private static boolean isChatMessage(Node event) { + if (event == null) { + return false; + } + Node type = event.getType(); + if (type != null) { + return ChatMessage.qualifiedName().equals(type.getValue()) + || ChatMessage.blueId().equals(type.getBlueId()); + } + if (event.getProperties() == null) { + return false; + } + Node typeProperty = event.getProperties().get("type"); + Object value = typeProperty != null ? typeProperty.getValue() : null; + return ChatMessage.qualifiedName().equals(value) || ChatMessage.typeName().equals(value); + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/src/test/java/blue/contract/processor/conversation/TestTimelineProvider.java b/src/test/java/blue/coordination/processor/TestTimelineProvider.java similarity index 64% rename from src/test/java/blue/contract/processor/conversation/TestTimelineProvider.java rename to src/test/java/blue/coordination/processor/TestTimelineProvider.java index 203db9e..bfb9a14 100644 --- a/src/test/java/blue/contract/processor/conversation/TestTimelineProvider.java +++ b/src/test/java/blue/coordination/processor/TestTimelineProvider.java @@ -1,33 +1,30 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; import blue.language.Blue; import blue.language.model.Node; -import blue.language.model.TypeBlueId; import blue.language.processor.ChannelCheckpointContext; import blue.language.processor.ChannelEvaluation; import blue.language.processor.ChannelEvaluationContext; import blue.language.processor.ChannelProcessor; import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.Timeline; -import blue.repo.v1_3_0.conversation.TimelineChannel; -import blue.repo.v1_3_0.conversation.TimelineEntry; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.Timeline; +import blue.repo.coordination.TimelineChannel; +import blue.repo.coordination.TimelineEntry; import java.math.BigInteger; public final class TestTimelineProvider { - public static final String SIMPLE_TIMELINE_CHANNEL_BLUE_ID = "test-simple-timeline-channel"; - private TestTimelineProvider() { } public static Blue registerWith(Blue blue) { - blue.registerContractProcessor(new SimpleTimelineChannelProcessor()); + blue.registerContractProcessor(TimelineChannel.blueId(), new SimpleTimelineChannelProcessor()); return blue; } public static Node channel(String timelineId) { - Node channel = new Node().type(new Node().blueId(SIMPLE_TIMELINE_CHANNEL_BLUE_ID)); + Node channel = new Node().type(TimelineChannel.qualifiedName()); if (timelineId != null) { channel.properties("timelineId", new Node().value(timelineId)); } @@ -50,7 +47,9 @@ public static Node timelineEntry(Blue blue, .properties("timeline", blue.objectToNode(entry.getTimeline())) .properties("timestamp", new Node().value(entry.getTimestamp())) .properties("message", entry.getMessage()); - return blue.preprocess(event); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor( + CoordinationTestResources.testTypeAliases(repository)).preprocess(event); + return blue.preprocess(aliasesResolved).blue(null); } public static Node chatMessage(String message) { @@ -60,28 +59,24 @@ public static Node chatMessage(String message) { .properties("message", new Node().value(chatMessage.getMessage())); } - @TypeBlueId(TestTimelineProvider.SIMPLE_TIMELINE_CHANNEL_BLUE_ID) - public static final class SimpleTimelineChannel extends TimelineChannel { - } - - public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { + public static final class SimpleTimelineChannelProcessor implements ChannelProcessor { @Override - public Class contractType() { - return SimpleTimelineChannel.class; + public Class contractType() { + return TimelineChannel.class; } @Override - public ChannelEvaluation evaluate(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + public ChannelEvaluation evaluate(TimelineChannel contract, ChannelEvaluationContext context) { return TimelineProviderSupport.evaluateTimelineEntry(contract, context); } @Override - public String eventId(SimpleTimelineChannel contract, ChannelEvaluationContext context) { + public String eventId(TimelineChannel contract, ChannelEvaluationContext context) { return TimelineProviderSupport.eventId(context.event()); } @Override - public boolean isNewerEvent(SimpleTimelineChannel contract, ChannelCheckpointContext context) { + public boolean isNewerEvent(TimelineChannel contract, ChannelCheckpointContext context) { return TimelineProviderSupport.isNewerOrSameTimelineEvent(context); } } diff --git a/src/test/java/blue/contract/processor/conversation/TimelineChannelProcessorTest.java b/src/test/java/blue/coordination/processor/TimelineChannelProcessorTest.java similarity index 93% rename from src/test/java/blue/contract/processor/conversation/TimelineChannelProcessorTest.java rename to src/test/java/blue/coordination/processor/TimelineChannelProcessorTest.java index f524d22..bea9bbe 100644 --- a/src/test/java/blue/contract/processor/conversation/TimelineChannelProcessorTest.java +++ b/src/test/java/blue/coordination/processor/TimelineChannelProcessorTest.java @@ -1,6 +1,6 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; +import blue.coordination.processor.CoordinationProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; @@ -140,9 +140,8 @@ private static Node initializedDocument(Fixture fixture, String timelineId) { private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } @@ -169,20 +168,20 @@ private static Node timelineEntryEvent(Fixture fixture, String timelineId, int t private static Node chatMessageEvent(Fixture fixture, String message) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("message", new Node().value(message)); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node misleadingChatMessageEvent(Fixture fixture) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Chat Message") + .type("Coordination/Chat Message") .properties("timeline", new Node() .properties("timelineId", new Node().value("owner"))) .properties("timestamp", new Node().value(10)) .properties("message", new Node().value("misleading")); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node checkpointEvent(Node document) { @@ -208,7 +207,13 @@ private static String checkpointSignature(Node document) { } private static Node property(Node node, String key) { - if (node == null || node.getProperties() == null) { + if (node == null) { + return null; + } + if ("contracts".equals(key)) { + return node.getContracts(); + } + if (node.getProperties() == null) { return null; } return node.getProperties().get(key); diff --git a/src/test/java/blue/contract/processor/conversation/TriggerEventStepExecutorTest.java b/src/test/java/blue/coordination/processor/TriggerEventStepExecutorTest.java similarity index 60% rename from src/test/java/blue/contract/processor/conversation/TriggerEventStepExecutorTest.java rename to src/test/java/blue/coordination/processor/TriggerEventStepExecutorTest.java index ec8f80b..368b3df 100644 --- a/src/test/java/blue/contract/processor/conversation/TriggerEventStepExecutorTest.java +++ b/src/test/java/blue/coordination/processor/TriggerEventStepExecutorTest.java @@ -1,13 +1,13 @@ -package blue.contract.processor.conversation; +package blue.coordination.processor; -import blue.contract.processor.BlueDocumentProcessors; +import blue.coordination.processor.CoordinationProcessors; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.DocumentProcessingResult; -import blue.language.processor.ProcessorFatalException; +import blue.language.processor.ProcessorStatus; import blue.repo.BlueRepository; -import blue.repo.v1_3_0.conversation.ChatMessage; -import blue.repo.v1_3_0.conversation.StatusCompleted; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.StatusCompleted; import java.math.BigInteger; import java.util.LinkedHashMap; import java.util.List; @@ -16,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class TriggerEventStepExecutorTest { @@ -36,38 +35,13 @@ void emitsStaticEventPayload() { } @Test - void resolvesNamedStepResults() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, - 0, - javaScriptStep("PreparePayment", "return { amount: 125, description: \"Subscription renewal\" };"), - triggerEventStep(chatMessageEvent("Prepared ${steps.PreparePayment.amount} USD")))); - - DocumentProcessingResult result = processChat(fixture, document); - - assertTriggeredChatMessage(result, "Prepared 125 USD"); - } - - @Test - void supportsDocumentAndCurrentContractBindings() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, - 3, - "Demo trigger workflow", - triggerEventStep(chatMessageEvent("Counter ${document('/counter')} in ${currentContract.description} / ${currentContractCanonical.description.value}")))); - - DocumentProcessingResult result = processChat(fixture, document); - - assertTriggeredChatMessage(result, "Counter 3 in Demo trigger workflow / Demo trigger workflow"); - } - - @Test - void fullExpressionsPreserveNonStringValues() { + void staticPayloadPreservesNonStringValues() { Fixture fixture = configuredFixture(); Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, 1, triggerEventStep(new Node() - .properties("amount", new Node().value("${document('/counter') + 1}"))))); + .type("Coordination/Event") + .properties("amount", new Node().value(2))))); DocumentProcessingResult result = processChat(fixture, document); @@ -75,63 +49,15 @@ void fullExpressionsPreserveNonStringValues() { } @Test - void templateExpressionsProduceStrings() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, - 1, - triggerEventStep(chatMessageEvent("Counter is ${document('/counter')}")))); - - DocumentProcessingResult result = processChat(fixture, document); - - assertTriggeredChatMessage(result, "Counter is 1"); - } - - @Test - void nestedListsAndObjectsResolve() { - Fixture fixture = configuredFixture(); - Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, - 7, - javaScriptStep("PreparePayment", "return { amount: 125 };"), - triggerEventStep(new Node() - .type("Conversation/Chat Message") - .properties("details", new Node() - .properties("values", new Node().items( - new Node().value("${document('/counter')}"), - new Node().value("after ${steps.PreparePayment.amount}"))))))); - - DocumentProcessingResult result = processChat(fixture, document); - Node event = result.triggeredEvents().get(0); - - assertEquals(BigInteger.valueOf(7), event.get("/details/values/0")); - assertEquals("after 125", event.get("/details/values/1")); - } - - @Test - void embeddedDocumentsStayLiteral() { + void bexOperatorPayloadFailsClearly() { Fixture fixture = configuredFixture(); Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, 0, - javaScriptStep("Prepare", "return { name: \"Worker\", secret: \"keep-literal\" };"), - triggerEventStep(new Node() - .type("Conversation/Chat Message") - .properties("message", new Node().value("Launching ${steps.Prepare.name}")) - .properties("document", new Node() - .name("Child Worker Session") - .properties("token", new Node().value("")) - .properties("contracts", new Node() - .properties("nestedWorkflow", new Node() - .type("Conversation/Sequential Workflow") - .properties("steps", new Node().items( - updateDocumentStep("replace", - "/token", - new Node().value("${steps.Prepare.secret}")))))))))); + triggerEventStep(new Node().properties("$document", new Node().value("/counter"))))); DocumentProcessingResult result = processChat(fixture, document); - Node event = result.triggeredEvents().get(0); - assertEquals("Launching Worker", event.get("/message")); - assertEquals("${steps.Prepare.secret}", - event.get("/document/contracts/nestedWorkflow/steps/0/changeset/0/val")); + assertRuntimeFatal(result, "Trigger Event event must be static"); } @Test @@ -139,12 +65,11 @@ void missingEventFailsClearly() { Fixture fixture = configuredFixture(); Node document = initializedDocument(fixture, directWorkflowDocument(fixture.repository, 0, - new Node().type("Conversation/Trigger Event"))); + new Node().type("Coordination/Trigger Event"))); - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processChat(fixture, document)); + DocumentProcessingResult result = processChat(fixture, document); - assertTrue(ex.getMessage().contains("Trigger Event step must declare event payload")); + assertRuntimeFatal(result, "Trigger Event step must declare event payload"); } @Test @@ -154,16 +79,15 @@ void namedEventOnlyFailsClearlyAsMissingSemanticPayload() { 0, triggerEventStep(new Node().name("Named Event Only")))); - ProcessorFatalException ex = assertThrows(ProcessorFatalException.class, - () -> processChat(fixture, document)); + DocumentProcessingResult result = processChat(fixture, document); // Trigger Event requires semantic payload content such as type, value, // properties, items, or a blueId; name/description-only metadata is not emitted. - assertTrue(ex.getMessage().contains("Trigger Event step must declare event payload")); + assertRuntimeFatal(result, "Trigger Event step must declare event payload"); } @Test - void emittedEventIsDeliveredToRealCoreTriggeredChannel() { + void emittedEventIsDeliveredToRuntimeTriggeredChannel() { Fixture fixture = configuredFixture(); Node document = initializedDocument(fixture, triggeredConsumerDocument(fixture.repository)); @@ -207,7 +131,7 @@ private static Node directWorkflowDocument(BlueRepository repository, Node... steps) { Map contracts = ownerChannelContracts(); Node workflow = new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("ownerChannel")) .properties("steps", new Node().items(steps)); if (description != null) { @@ -220,16 +144,16 @@ private static Node directWorkflowDocument(BlueRepository repository, private static Node triggeredConsumerDocument(BlueRepository repository) { Map contracts = ownerChannelContracts(); contracts.put("triggered", new Node() - .type("Core/Triggered Event Channel")); + .type("Triggered Event Channel")); contracts.put("producer", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("ownerChannel")) .properties("steps", new Node().items( - triggerEventStep(new Node().type("Conversation/Status Completed"))))); + triggerEventStep(new Node().type("Coordination/Status Completed"))))); contracts.put("consumer", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("triggered")) - .properties("event", new Node().type("Conversation/Status Completed")) + .properties("event", new Node().type("Coordination/Status Completed")) .properties("steps", new Node().items( triggerEventStep(chatMessageEvent("Triggered consumer ran"))))); return document(repository, 0, contracts); @@ -238,19 +162,19 @@ private static Node triggeredConsumerDocument(BlueRepository repository) { private static Node lifecycleProducerDocument(BlueRepository repository) { Map contracts = new LinkedHashMap(); contracts.put("life", new Node() - .type("Core/Lifecycle Event Channel")); + .type("Lifecycle Event Channel")); contracts.put("triggered", new Node() - .type("Core/Triggered Event Channel")); + .type("Triggered Event Channel")); contracts.put("onInit", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("life")) - .properties("event", new Node().type("Core/Document Processing Initiated")) + .properties("event", new Node().type("Document Processing Initiated")) .properties("steps", new Node().items( - triggerEventStep(new Node().type("Conversation/Status Completed"))))); + triggerEventStep(new Node().type("Coordination/Status Completed"))))); contracts.put("consumer", new Node() - .type("Conversation/Sequential Workflow") + .type("Coordination/Sequential Workflow") .properties("channel", new Node().value("triggered")) - .properties("event", new Node().type("Conversation/Status Completed")) + .properties("event", new Node().type("Coordination/Status Completed")) .properties("steps", new Node().items( triggerEventStep(chatMessageEvent("Init triggered consumer"))))); return document(repository, 0, contracts); @@ -264,30 +188,18 @@ private static Map ownerChannelContracts() { private static Node triggerEventStep(Node event) { return new Node() - .type("Conversation/Trigger Event") + .type("Coordination/Trigger Event") .properties("event", event); } - private static Node javaScriptStep(String name, String code) { - return new Node() - .name(name) - .type("Conversation/JavaScript Code") - .properties("code", new Node().value(code)); - } - - private static Node updateDocumentStep(String op, String path, Node value) { - return new Node() - .type("Conversation/Update Document") - .properties("changeset", new Node().items(new Node() - .properties("op", new Node().value(op)) - .properties("path", new Node().value(path)) - .properties("val", value))); + private static Node chatMessageEvent(String message) { + return chatMessageEvent(new Node().value(message)); } - private static Node chatMessageEvent(String message) { + private static Node chatMessageEvent(Node message) { return new Node() - .type("Conversation/Chat Message") - .properties("message", new Node().value(message)); + .type("Coordination/Chat Message") + .properties("message", message); } private static Node document(BlueRepository repository, int counter, Map contracts) { @@ -305,12 +217,12 @@ private static DocumentProcessingResult processChat(Fixture fixture, Node docume private static Node chatTimelineEntry(Fixture fixture) { Node event = new Node() .blue(fixture.repository.typeAliasBlue()) - .type("Conversation/Timeline Entry") + .type("Coordination/Timeline Entry") .properties("timeline", new Node() .properties("timelineId", new Node().value("owner"))) .properties("timestamp", new Node().value(1)) .properties("message", chatMessageEvent("run")); - return fixture.blue.preprocess(event); + return fixture.blue.preprocess(event).blue(null); } private static Node initializedDocument(Fixture fixture, Node document) { @@ -319,9 +231,8 @@ private static Node initializedDocument(Fixture fixture, Node document) { private static Fixture configuredFixture() { BlueRepository repository = BlueRepository.v1_3_0(); - Blue blue = repository.configure(new Blue()); - blue.nodeProvider(repository.nodeProvider()); - BlueDocumentProcessors.registerWith(blue); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); TestTimelineProvider.registerWith(blue); return new Fixture(repository, blue); } @@ -355,6 +266,12 @@ private static void assertEventType(Node event, String qualifiedName, String blu "Expected event type " + qualifiedName + " but was " + event); } + private static void assertRuntimeFatal(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null && result.failureReason().contains(expectedMessage), + result.failureReason()); + } + private static boolean isEventType(Node event, String qualifiedName, String blueId) { if (event == null) { return false; diff --git a/src/test/java/blue/coordination/processor/compute/BexCounterPersistenceRoundTripTest.java b/src/test/java/blue/coordination/processor/compute/BexCounterPersistenceRoundTripTest.java new file mode 100644 index 0000000..a0f355c --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/BexCounterPersistenceRoundTripTest.java @@ -0,0 +1,124 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import java.math.BigInteger; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Scenario: + * A production-style counter document is initialized once, stored as canonical data, then reloaded for + * every incoming event before being processed and stored again. + * + * Main flow: + * 1. Initialize the BEX counter document and serialize the canonical result. + * 2. Run 100 independent {@code increment} operation requests, each adding 1 to {@code /counter}. + * 3. Before each increment, deserialize the previously stored canonical document and load a snapshot. + * 4. After each increment, serialize the new canonical document for the next iteration. + * + * Actors and operations: + * - The owner timeline calls {@code increment}. + * - {@code Coordination/Compute} builds and applies the returned changeset. + */ +class BexCounterPersistenceRoundTripTest { + private static final int ITERATIONS = 100; + private static final String COUNTER_RESOURCE = "coordination/compute/bex-counter-persistence.yaml"; + + @Test + void serializedCanonicalDocumentCanBeReloadedAndProcessedAcrossOneHundredBexIncrements() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + + long start = System.nanoTime(); + + long initializeStart = System.nanoTime(); + DocumentProcessingResult initialized = support.initialize(support.yamlResource(COUNTER_RESOURCE)); + long initializeNanos = System.nanoTime() - initializeStart; + assertFalse(initialized.capabilityFailure(), initialized.failureReason()); + assertNotNull(initialized.snapshot()); + + long initialSerializeStart = System.nanoTime(); + String storedCanonicalJson = serializeCanonical(support, initialized); + long initialSerializeNanos = System.nanoTime() - initialSerializeStart; + String storedBlueId = initialized.blueId(); + assertNotNull(storedBlueId); + + long totalProcessNanos = 0L; + long totalDeserializeAndLoadSnapshotNanos = 0L; + long totalSerializeNanos = 0L; + + for (int i = 1; i <= ITERATIONS; i++) { + long loadStart = System.nanoTime(); + ResolvedSnapshot snapshot = deserializeCanonicalAndLoadSnapshot(support, storedCanonicalJson); + totalDeserializeAndLoadSnapshotNanos += System.nanoTime() - loadStart; + assertNotNull(snapshot.blueId(), "stored snapshot should load at iteration " + i); + storedBlueId = snapshot.blueId(); + + long processStart = System.nanoTime(); + DocumentProcessingResult result = support.blue.processDocument(snapshot, + support.operationRequest("increment", new Node().value(1))); + totalProcessNanos += System.nanoTime() - processStart; + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertNotNull(result.snapshot(), "iteration " + i + " should return a snapshot"); + assertEquals(BigInteger.valueOf(i), result.resolvedDocument().get("/counter")); + + long serializeStart = System.nanoTime(); + storedCanonicalJson = serializeCanonical(support, result); + storedBlueId = result.blueId(); + totalSerializeNanos += System.nanoTime() - serializeStart; + } + + long totalNanos = System.nanoTime() - start; + ResolvedSnapshot finalSnapshot = deserializeCanonicalAndLoadSnapshot(support, storedCanonicalJson); + assertEquals(BigInteger.valueOf(ITERATIONS), finalSnapshot.resolvedNodeAt("/counter").getValue()); + assertEquals(ITERATIONS, metrics.updateBatchPatchApplications()); + assertEquals(ITERATIONS, metrics.directBexChangesetHits()); + assertEquals(0L, metrics.updateIndividualPatchApplications()); + + System.out.printf("BEX counter persistence round trip - iterations=%d, finalBlueId=%s, totalMs=%.3f, " + + "initializeMs=%.3f, initialSerializeMs=%.3f, deserializeLoadSnapshotMs=%.3f, " + + "processMs=%.3f, serializeMs=%.3f, " + + "batchPatchApplications=%d, bundleCacheHits=%d, bundleCacheMisses=%d%n", + ITERATIONS, + storedBlueId, + nanosToMs(totalNanos), + nanosToMs(initializeNanos), + nanosToMs(initialSerializeNanos), + nanosToMs(totalDeserializeAndLoadSnapshotNanos), + nanosToMs(totalProcessNanos), + nanosToMs(totalSerializeNanos), + metrics.updateBatchPatchApplications(), + metrics.bundleLoadCacheHits(), + metrics.bundleLoadCacheMisses()); + } + + private static String serializeCanonical(ComputeWorkflowTestSupport support, DocumentProcessingResult result) { + assertNotNull(result.canonicalDocument()); + return support.blue.nodeToJson(result.canonicalDocument()); + } + + private static ResolvedSnapshot deserializeCanonicalAndLoadSnapshot(ComputeWorkflowTestSupport support, + String storedCanonicalJson) { + Node storedCanonical = support.blue.parseSourceJson(storedCanonicalJson); + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(storedCanonical); + return new ResolvedSnapshot(canonicalRoot, + FrozenNode.fromResolvedNode(storedCanonical), + canonicalRoot.blueId()); + } + + private static double nanosToMs(long nanos) { + return nanos / 1_000_000.0; + } +} diff --git a/src/test/java/blue/coordination/processor/compute/BexCounterResourceWorkflowTest.java b/src/test/java/blue/coordination/processor/compute/BexCounterResourceWorkflowTest.java new file mode 100644 index 0000000..80bb800 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/BexCounterResourceWorkflowTest.java @@ -0,0 +1,75 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.TestTimelineProvider; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.repo.BlueRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Scenario: + * A small YAML counter document proves the resource-based BEX workflow used by examples and smoke tests. + * + * Main flow: + * 1. Load {@code coordination/counter-bex.yaml} from test resources. + * 2. Initialize the document. + * 3. Send one {@code increment} operation request with value 1 through a simple timeline channel. + * 4. Assert that the document counter is incremented and a chat message is emitted. + * + * Actors and operations: + * - The owner timeline calls {@code increment}. + * - BEX compute mutates {@code /counter} from the returned changeset and emits the chat message. + */ +class BexCounterResourceWorkflowTest { + private static final String COUNTER_RESOURCE = "/coordination/counter-bex.yaml"; + private static final String TIMELINE_ID = "counter-timeline"; + + @Test + void counterBexWorkflowProcessesTimelineIncrementOperation() { + Fixture fixture = configuredFixture(); + Node document = CoordinationTestResources.yamlResource(fixture.blue, fixture.repository, COUNTER_RESOURCE); + DocumentProcessingResult initialized = fixture.blue.initializeDocument(document); + Node event = CoordinationTestResources.operationRequestEvent(fixture.blue, + fixture.repository, + TIMELINE_ID, + 1700000001, + "increment", + new Node().value(1)); + + DocumentProcessingResult result = fixture.blue.processDocument(initialized.document(), event); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertNotNull(result.document()); + assertEquals(BigInteger.ONE, result.document().get("/counter")); + assertEquals(1, result.triggeredEvents().size()); + assertEquals("Counter was incremented by 1 and is now 1", + result.triggeredEvents().get(0).getAsText("/message")); + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java new file mode 100644 index 0000000..79873f2 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java @@ -0,0 +1,887 @@ +package blue.coordination.processor.compute; + +import blue.bex.api.BexEngine; +import blue.bex.api.BexMetricsSink; +import blue.bex.result.BexMetrics; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; +import blue.coordination.processor.workflow.StepExecutionContext; +import blue.coordination.processor.workflow.WorkflowStepExecutor; +import blue.coordination.processor.workflow.WorkflowStepResult; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflowStep; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Scenario: + * Primary {@code Coordination/Compute} behavior is verified with direct patch and event effects. + * + * Main flow: + * 1. Execute inline Compute programs and Compute Definition backed programs. + * 2. Prove Compute can read {@code $document}, {@code $event}, {@code $steps}, and + * {@code $currentContract}. + * 3. Prove Compute can apply returned changesets, emit events, return step results, and consume gas. + * 4. Prove returned changesets remain readable as step result data after being applied. + * 5. Keep Trigger Event and literal Update Document compatibility intact. + * + * Actors and operations: + * - The owner timeline calls {@code run}. + * - Compute steps build patches, data, and events. + * - Later Compute steps read prior named step results. + * - Compatibility cases ensure existing non-BEX workflow executors still work. + */ +class ComputeWorkflowExecutionTest { + @Test + void inlineComputeEmitsEventAndDoesNotMutateDocument() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Compute Event", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("idle", result.document().get("/status")); + assertEquals(1, result.triggeredEvents().size()); + assertEquals("Compute Event", result.triggeredEvents().get(0).get("/kind")); + } + + @Test + void inlineComputeResultIsReadableByLaterComputeViaSteps() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " approved: true", + " reason: ok", + " - name: ReadPrior", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Prior Result", + " approved:", + " $steps: Build.approved", + " reason:", + " $steps: Build.reason", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + Node event = onlyEvent(result); + + assertEquals("Prior Result", event.get("/kind")); + assertEquals(Boolean.TRUE, event.get("/approved")); + assertEquals("ok", event.get("/reason")); + } + + @Test + void emitEventsFalseSuppressesComputedEvents() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " emitEvents: false", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Should Not Emit", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertTrue(result.triggeredEvents().isEmpty()); + } + + @Test + void emitEventsFalseStillExportsStepResult() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " emitEvents: false", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Should Not Emit", + " - $return:", + " approved: true", + " - name: Read", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Exported Result", + " approved:", + " $steps: Build.approved", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Exported Result", onlyEvent(result).get("/kind")); + assertEquals(Boolean.TRUE, onlyEvent(result).get("/approved")); + } + + @Test + void returnResultFalseSuppressesStepResult() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " returnResult: false", + " do:", + " - $return:", + " approved: true", + " - name: ReadPrior", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Missing Prior", + " approved:", + " $coalesce:", + " - $steps: Build.approved", + " - missing", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("missing", onlyEvent(result).get("/approved")); + } + + @Test + void returnResultFalseStillAllowsEventEmission() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " returnResult: false", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Event Still Emits", + " - $return:", + " approved: true")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Event Still Emits", onlyEvent(result).get("/kind")); + } + + @Test + void unnamedComputeStepExportsAsStepIndexKey() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - type: Coordination/Compute", + " do:", + " - $return:", + " value: abc", + " - name: Read", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind:", + " $steps: Step1.value", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("abc", onlyEvent(result).get("/kind")); + } + + @Test + void computeChangesetAppliesAndRemainsStepData() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: BuildPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val: active", + " - $return: {}", + " - name: VerifyPatchData", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Patch Data", + " patchPath:", + " $steps:", + " step: BuildPatch", + " path: /changeset/0/path", + " patchValue:", + " $steps:", + " step: BuildPatch", + " path: /changeset/0/val", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("active", result.document().get("/status")); + assertEquals("/status", onlyEvent(result).get("/patchPath")); + assertEquals("active", onlyEvent(result).get("/patchValue")); + } + + @Test + void explicitEmptyChangesetSuppressesAccumulatedChanges() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: BuildPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val: active", + " - $return:", + " changeset: []")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("idle", result.document().get("/status")); + } + + @Test + void returnResultFalseStillAppliesChangeset() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: BuildPatch", + " type: Coordination/Compute", + " returnResult: false", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val: active", + " - $return:", + " ignored: true")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("active", result.document().get("/status")); + } + + @Test + void inlineExprComputeExportsScalarResult() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: ReadStatus", + " type: Coordination/Compute", + " expr:", + " $document: /status", + " - name: EmitStatus", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Status", + " status:", + " $steps: ReadStatus", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("idle", onlyEvent(result).get("/status")); + } + + @Test + void computeReadsEventDocumentAndCurrentContract() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Inputs", + " request:", + " $event: /message/request", + " status:", + " $document: /status", + " channel:", + " $currentContract: /channel", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document, new Node().value("hello")); + Node event = onlyEvent(result); + + assertEquals("hello", event.get("/request")); + assertEquals("idle", event.get("/status")); + assertEquals("ownerChannel", event.get("/channel")); + } + + @Test + void currentContractChannelBindingPreservesAuthoredChannel() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(String.join("\n", + "name: Compute Authored Channel Test", + "status: idle", + "contracts:", + CoordinationTestResources.simpleTimelineChannelYaml("manualChannel", "owner", 2), + " run:", + " type: Coordination/Operation", + " channel: manualChannel", + " runImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: run", + " channel: manualChannel", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Authored Channel", + " channel:", + " $currentContract: /channel", + " - $return: {}"))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("manualChannel", onlyEvent(result).get("/channel")); + } + + @Test + void computeDefinitionCanBeReferencedBySiblingContractKey() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " constants:", + " kind: From Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind:", + " $const: kind", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: computeLogic", + " entry: build")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("From Definition", onlyEvent(result).get("/kind")); + } + + @Test + void computeDefinitionCanBeReferencedByAbsolutePointer() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Absolute Definition", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: /contracts/computeLogic", + " entry: build")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Absolute Definition", onlyEvent(result).get("/kind")); + } + + @Test + void inlineObjectComputeDefinitionWorks() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition:", + " constants:", + " kind: Inline Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind:", + " $const: kind", + " - $return: {}", + " entry: build")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Inline Definition", onlyEvent(result).get("/kind")); + } + + @Test + void computeDefinitionMarkerDoesNotExecuteByItself() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Should Not Happen", + " - $return: {}"), + String.join("\n", + " steps: []")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertTrue(result.triggeredEvents().isEmpty()); + } + + @Test + void missingDefinitionFailsClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: missingCompute", + " entry: build")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute definition not found"); + } + + @Test + void missingEntryFailsClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " functions:", + " build:", + " do:", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: computeLogic", + " entry: missing")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Unknown entry function"); + } + + @Test + void stepConstantsOverrideDefinitionConstants() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " constants:", + " kind: From Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind:", + " $const: kind", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: computeLogic", + " entry: build", + " constants:", + " kind: From Step")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("From Step", onlyEvent(result).get("/kind")); + } + + @Test + void definitionReferenceEscapesJsonPointerSegments() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts(String.join("\n", + " \"compute/logic~v1\":", + " type: Coordination/Compute Definition", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Escaped Definition", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: compute/logic~v1", + " entry: build")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Escaped Definition", onlyEvent(result).get("/kind")); + } + + @Test + void localFunctionsWorkWithoutDefinition() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " entry: build", + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Local Function", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("Local Function", onlyEvent(result).get("/kind")); + } + + @Test + void gasLimitFailureAndDefaultGasLimitFromOptionsFailClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " gasLimit: 1", + " do:", + " - $return:", + " ok: true")); + + DocumentProcessingResult explicit = support.processRun(document); + assertRuntimeFatalIgnoreCase(explicit, "gas"); + + ComputeWorkflowTestSupport lowDefault = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder().defaultComputeGasLimit(1L).build()); + Node lowDefaultDocument = lowDefault.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " ok: true")); + DocumentProcessingResult defaultFailure = lowDefault.processRun(lowDefaultDocument); + assertRuntimeFatalIgnoreCase(defaultFailure, "gas"); + + ComputeWorkflowTestSupport normalDefault = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder().defaultComputeGasLimit(100_000L).build()); + Node normalDocument = normalDefault.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " ok: true")); + + assertFalse(normalDefault.processRun(normalDocument).capabilityFailure()); + } + + @Test + void defaultComputeGasLimitMustBePositive() { + IllegalArgumentException zero = assertThrows(IllegalArgumentException.class, + () -> CoordinationProcessorOptions.builder().defaultComputeGasLimit(0L)); + assertTrue(zero.getMessage().contains("defaultComputeGasLimit must be positive")); + + IllegalArgumentException negative = assertThrows(IllegalArgumentException.class, + () -> CoordinationProcessorOptions.builder().defaultComputeGasLimit(-1L)); + assertTrue(negative.getMessage().contains("defaultComputeGasLimit must be positive")); + } + + @Test + void explicitResultEventsAndAccumulatorEventsAreEmitted() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Explicit", + " type: Coordination/Compute", + " do:", + " - $return:", + " events:", + " - type: Coordination/Event", + " kind: Explicit Events", + " changeset: []", + " - name: Accumulator", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Accumulator Event", + " - $return:", + " approved: true")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals(2, result.triggeredEvents().size()); + assertEquals("Explicit Events", result.triggeredEvents().get(0).get("/kind")); + assertEquals("Accumulator Event", result.triggeredEvents().get(1).get("/kind")); + } + + @Test + void invalidEventsFieldFailsClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " events: not-a-list")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute result events must be a list"); + } + + @Test + void invalidChangesetFieldFailsClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " changeset: not-a-list")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute result changeset must be a list"); + } + + @Test + void scalarChangesetEntriesFailClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " changeset:", + " - hello")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute result changeset entry 0 must be an object"); + } + + @Test + void scalarEventEntriesFailClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " events:", + " - hello")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute result events must contain object entries"); + } + + @Test + void nullEventEntriesFailClosed() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " events:", + " - null")); + + DocumentProcessingResult result = support.processRun(document); + + assertRuntimeFatal(result, "Compute result events must contain object entries"); + } + + @Test + void pureComputeWorkflowRunsWithBexOnlyRunner() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .sequentialWorkflowRunner(SequentialWorkflowRunner.withBexEngine( + BexEngine.builder().build(), + 100_000L)) + .build()); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: BEX Only", + " - $return: {}")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals("BEX Only", onlyEvent(result).get("/kind")); + } + + @Test + void literalTriggerAndUpdateDocumentStepsStillWork() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Apply", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val: 42", + " - name: Trigger", + " type: Coordination/Trigger Event", + " event:", + " type: Coordination/Event", + " kind: Existing Trigger", + " status: static")); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals(BigInteger.valueOf(42), result.document().get("/status")); + assertEquals("Existing Trigger", onlyEvent(result).get("/kind")); + assertEquals("static", onlyEvent(result).get("/status")); + } + + @Test + void bexEngineCompileCacheIsUsedAcrossRuns() { + final List metrics = new ArrayList(); + BexEngine engine = BexEngine.builder().metrics(new BexMetricsSink() { + @Override + public void accept(BexMetrics item) { + metrics.add(item.copy()); + } + }).build(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder().bexEngine(engine).build()); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " expr:", + " $document: /status")); + + Node afterFirst = support.processRun(document).document(); + support.processRun(afterFirst); + + long hits = 0L; + for (BexMetrics item : metrics) { + hits += item.compileCacheHits(); + } + assertTrue(hits > 0L); + } + + @Test + void runnerProvidesFrozenStepAndContractNodesToExecutors() { + final AtomicBoolean sawFrozenStep = new AtomicBoolean(false); + final AtomicBoolean sawFrozenContract = new AtomicBoolean(false); + WorkflowStepExecutor executor = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof Compute; + } + + @Override + public WorkflowStepResult execute(Compute step, StepExecutionContext context) { + sawFrozenStep.set(context.stepFrozenNode() != null); + sawFrozenContract.set(context.currentContractFrozenNode() != null); + return WorkflowStepResult.none(); + } + }; + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .sequentialWorkflowRunner(new SequentialWorkflowRunner( + Collections.>singletonList(executor))) + .build()); + Node document = support.initializedOperationWorkflow(String.join("\n", + " largePayload:", + " item000: value000", + " item001: value001", + " item002: value002", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return: {}")); + + support.processRun(document); + + assertTrue(sawFrozenStep.get()); + assertTrue(sawFrozenContract.get()); + } + + private static Node onlyEvent(DocumentProcessingResult result) { + assertEquals(1, result.triggeredEvents().size()); + return result.triggeredEvents().get(0); + } + + private static void assertRuntimeFatal(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null && result.failureReason().contains(expectedMessage), + result.failureReason()); + } + + private static void assertRuntimeFatalIgnoreCase(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null + && result.failureReason().toLowerCase().contains(expectedMessage.toLowerCase()), + result.failureReason()); + } +} diff --git a/src/test/java/blue/coordination/processor/compute/ComputeWorkflowTestSupport.java b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowTestSupport.java new file mode 100644 index 0000000..2c20be4 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowTestSupport.java @@ -0,0 +1,116 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.TestTimelineProvider; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.repo.BlueRepository; + +final class ComputeWorkflowTestSupport { + private int timestamp = 1; + + final BlueRepository repository; + final Blue blue; + + private ComputeWorkflowTestSupport(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + + static ComputeWorkflowTestSupport create() { + return create(null); + } + + static ComputeWorkflowTestSupport create(CoordinationProcessorOptions options) { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue, options); + TestTimelineProvider.registerWith(blue); + return new ComputeWorkflowTestSupport(repository, blue); + } + + Node yaml(String source) { + Node node = blue.parseSourceYaml(source); + node.blue(repository.typeAliasBlue()); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor(repository).preprocess(node); + return blue.preprocess(aliasesResolved); + } + + Node yamlResource(String resourcePath) { + return CoordinationTestResources.yamlResource(blue, repository, resourcePath); + } + + DocumentProcessingResult initialize(Node document) { + Node aliasesResolved = new RepositoryTypeAliasPreprocessor(repository).preprocess(document); + return blue.initializeDocument(blue.preprocess(aliasesResolved)); + } + + DocumentProcessingResult process(Node snapshot, Node event) { + return blue.processDocument(snapshot, event); + } + + DocumentProcessingResult processRun(Node snapshot) { + return processRun(snapshot, new Node().value("request")); + } + + DocumentProcessingResult processRun(Node snapshot, Node request) { + return process(snapshot, operationRequest("run", request)); + } + + Node operationRequest(String operation, Node request) { + return operationRequest("owner", timestamp++, operation, request); + } + + Node operationRequest(String timelineId, int timestamp, String operation, Node request) { + return CoordinationTestResources.operationRequestEvent(blue, + repository, + timelineId, + timestamp, + operation, + request); + } + + String operationWorkflowDocument(String body) { + return operationWorkflowDocumentWithContracts("", body); + } + + String operationWorkflowDocumentWithStatus(String rootFields, String body) { + return String.join("\n", + "name: Compute Workflow Test", + "status: idle", + rootFields, + "contracts:", + CoordinationTestResources.simpleTimelineChannelYaml("ownerChannel", "owner", 2), + " run:", + " type: Coordination/Operation", + " channel: ownerChannel", + " runImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: run", + body); + } + + String operationWorkflowDocumentWithContracts(String extraContracts, String body) { + return String.join("\n", + "name: Compute Workflow Test", + "status: idle", + "contracts:", + CoordinationTestResources.simpleTimelineChannelYaml("ownerChannel", "owner", 2), + " run:", + " type: Coordination/Operation", + " channel: ownerChannel", + extraContracts, + " runImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: run", + body); + } + + Node initializedOperationWorkflow(String body) { + return initialize(yaml(operationWorkflowDocument(body))).document(); + } +} diff --git a/src/test/java/blue/coordination/processor/compute/CustomerPaynoteLatestBexFixtureTest.java b/src/test/java/blue/coordination/processor/compute/CustomerPaynoteLatestBexFixtureTest.java new file mode 100644 index 0000000..e691565 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/CustomerPaynoteLatestBexFixtureTest.java @@ -0,0 +1,240 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.TestTimelineProvider; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.repo.BlueRepository; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Scenario: + * The large customer Paynote snapshot fixture is processed through the BEX-based document path. + * + * Main flow: + * 1. Load the latest Compute/BEX Paynote document fixture and its snapshot event fixture. + * 2. Initialize the document, process the supplied event, and time the processing call. + * 3. Verify the expected package-fulfillment document remains active and emits snapshot events. + * + * Actors and operations: + * - The incoming fixture event represents the external snapshot/update being processed. + * - Admin/update workflows emit snapshot-related events. + * - Compute and BEX handle data construction. + */ +class CustomerPaynoteLatestBexFixtureTest { + private static final String DOCUMENT_RESOURCE = + "/processor-delay/customer-paynote-snapshot.document.compute.latest-bex.yaml"; + private static final String EVENT_RESOURCE = + "/processor-delay/customer-paynote-snapshot.event.yaml"; + private static final String SNAPSHOT_RESOLVED_TYPE = + "Sample/Document Initial Snapshot Resolved"; + private static final String PROCESSING_INITIALIZED_MARKER = "Processing Initialized Marker"; + + @Test + void customerPaynoteLatestBexDocumentProcessesSnapshotEvent() { + Fixture fixture = configuredFixture(); + Node document = loadYaml(fixture, DOCUMENT_RESOURCE); + Node event = loadYaml(fixture, EVENT_RESOURCE); + stripNestedSnapshotDocuments(event); + retainAdminUpdateContracts(document); + + DocumentProcessingResult initialized = fixture.blue.initializeDocument(document); + long start = System.currentTimeMillis(); + DocumentProcessingResult result = fixture.blue.processDocument(initialized.document(), event); + System.out.println("Processing time: " + (System.currentTimeMillis() - start) + "ms"); + + assertNotNull(result.document()); + assertEquals("Global Package Fulfillment Automation - Weekend Stay + Wine Dinner", + result.document().getName()); + assertFalse(result.triggeredEvents().isEmpty(), + "Expected the admin update workflow to emit snapshot events; checkpoint timestamp=" + + result.document().get("/contracts/checkpoint/lastEvents/sampleAdminChannel/timestamp")); + assertContainsEventType(result, + SNAPSHOT_RESOLVED_TYPE, + CoordinationTestResources.testTypeAliases(fixture.repository).get(SNAPSHOT_RESOLVED_TYPE)); + assertEquals("active", result.document().get("/status")); + } + + private static Node loadYaml(Fixture fixture, String resourcePath) { + Node parsed = fixture.blue.parseSourceYaml(CoordinationTestResources.readResource(resourcePath)); + parsed.blue(fixture.repository.typeAliasBlue()); + if (EVENT_RESOURCE.equals(resourcePath)) { + stripNestedSnapshotDocuments(parsed); + } + Node aliasesResolved = new RepositoryTypeAliasPreprocessor( + CoordinationTestResources.testTypeAliases(fixture.repository)).preprocess(parsed); + Node preprocessed = fixture.blue.preprocess(aliasesResolved); + normalizeInitializationMarkers(preprocessed); + clearCheckpoint(preprocessed); + if (DOCUMENT_RESOURCE.equals(resourcePath)) { + preprocessed.type((Node) null); + } + return preprocessed; + } + + private static Fixture configuredFixture() { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static void assertContainsEventType(DocumentProcessingResult result, String expectedType, String expectedBlueId) { + for (Node event : result.triggeredEvents()) { + if (isEventType(event, expectedType, expectedBlueId)) { + return; + } + } + throw new AssertionError("Expected triggered event type: " + expectedType + + ", actual count: " + result.triggeredEvents().size() + + ", actual types: " + triggeredEventTypes(result) + + ", first event: " + (result.triggeredEvents().isEmpty() ? null : result.triggeredEvents().get(0))); + } + + private static boolean isEventType(Node event, String expectedType, String expectedBlueId) { + if (event == null) { + return false; + } + if (event.getType() != null) { + if (expectedBlueId != null && expectedBlueId.equals(event.getType().getBlueId())) { + return true; + } + Object value = event.getType().getValue(); + if (expectedType.equals(value)) { + return true; + } + } + Node typeProperty = property(event, "type"); + Object propertyValue = typeProperty != null ? typeProperty.getValue() : null; + return expectedType.equals(propertyValue); + } + + private static String triggeredEventTypes(DocumentProcessingResult result) { + StringBuilder builder = new StringBuilder(); + for (Node event : result.triggeredEvents()) { + if (builder.length() > 0) { + builder.append(", "); + } + Node type = event != null ? event.getType() : null; + builder.append(type != null ? type.getValue() : null) + .append("/") + .append(type != null ? type.getBlueId() : null) + .append(" field=") + .append(typeField(event)); + } + return builder.toString(); + } + + private static Object typeField(Node event) { + Node type = property(event, "type"); + return type != null ? type.getValue() : null; + } + + private static void normalizeInitializationMarkers(Node node) { + if (node == null) { + return; + } + Map properties = node.getProperties(); + if (properties != null) { + Node contracts = properties.get("contracts"); + if (contracts != null && contracts.getProperties() != null) { + normalizeInitializationMarker(contracts.getProperties().get("initialized")); + } + for (Node child : properties.values()) { + normalizeInitializationMarkers(child); + } + } + if (node.getItems() != null) { + for (Node item : node.getItems()) { + normalizeInitializationMarkers(item); + } + } + } + + private static void normalizeInitializationMarker(Node marker) { + if (marker == null || marker.getType() == null) { + return; + } + Node type = marker.getType(); + if (PROCESSING_INITIALIZED_MARKER.equals(type.getValue()) + || RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER.equals(type.getBlueId())) { + marker.type(new Node().blueId("InitializationMarker")); + } + } + + private static void clearCheckpoint(Node node) { + if (node == null) { + return; + } + Node contracts = property(node, "contracts"); + if (contracts != null && contracts.getProperties() != null) { + contracts.getProperties().remove("checkpoint"); + } + } + + private static Node property(Node node, String key) { + if (node == null) { + return null; + } + if ("contracts".equals(key)) { + return node.getContracts(); + } + return node.getProperties() != null ? node.getProperties().get(key) : null; + } + + private static void stripNestedSnapshotDocuments(Node event) { + // The attached event carries a full customer PayNote snapshot inside the + // admin request. That nested snapshot is not needed to prove the admin + // BEX workflow emits the request event, and retaining it forces checkpoint + // metadata to resolve stale embedded repository contracts. + Node message = property(event, "message"); + Node request = property(message, "request"); + if (request == null || request.getItems() == null) { + return; + } + for (Node item : request.getItems()) { + if (item.getProperties() != null) { + item.getProperties().remove("document"); + } + } + } + + private static void retainAdminUpdateContracts(Node document) { + // Keep the workflow under test from the attached document while avoiding + // unrelated generated contracts whose historical schema metadata is not + // needed for this event path. + Node contracts = property(document, "contracts"); + Map all = contracts.getProperties(); + Node channel = all.get("sampleAdminChannel"); + Node operation = all.get("sampleAdminUpdate"); + Node implementation = all.get("sampleAdminUpdateImpl"); + operation.getProperties().remove("request"); + implementation.getProperties().remove("event"); + implementation.properties("channel", new Node().value("sampleAdminChannel")); + all.clear(); + all.put("sampleAdminChannel", channel); + all.put("sampleAdminUpdate", operation); + all.put("sampleAdminUpdateImpl", implementation); + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/DynamicEmbeddedParticipantsWorkflowTest.java b/src/test/java/blue/coordination/processor/compute/DynamicEmbeddedParticipantsWorkflowTest.java new file mode 100644 index 0000000..0a35755 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/DynamicEmbeddedParticipantsWorkflowTest.java @@ -0,0 +1,130 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import java.math.BigInteger; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Scenario: + * A main document dynamically creates embedded participant documents and then listens to those embedded + * timelines through generated channels. + * + * Main flow: + * 1. Alice calls {@code createEmbedded} five times. + * 2. Each call adds one {@code /embedded_N} document, adds a simple timeline channel for it, and adds + * bridge/counter contracts that make the main document observe the embedded timeline. + * 3. Each embedded participant calls {@code say}, which emits a chat message from the embedded document. + * 4. The main document catches the embedded chat event, increments chat counters, and Bob calls + * {@code checkChatCount}. + * 5. Bob's check sets {@code /success} once the main document has seen five chat messages. + * + * Actors and operations: + * - Alice owns dynamic embedding through {@code createEmbedded}. + * - Embedded participants own their own simple timeline {@code say} operations. + * - Bob calls {@code checkChatCount} to mark success. + * - All mutations are returned BEX Compute changesets applied through batch patches. + */ +class DynamicEmbeddedParticipantsWorkflowTest { + private static final String DOCUMENT_RESOURCE = + "coordination/compute/dynamic-embedded-participants-bex.yaml"; + private static final int EMBEDDED_PARTICIPANTS = 5; + private static final int CHAT_MESSAGES = 5; + + @Test + void aliceAddsEmbeddedParticipantDocumentsAndBobWaitsUntilMainDocumentCountsFiveChats() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + + Node current = support.initialize(support.yamlResource(DOCUMENT_RESOURCE)).document(); + + assertNotNull(current.getAsNode("/embeddedTemplate")); + assertNotNull(current.getAsNode("/contractTemplates/embeddedTimeline")); + assertNotNull(current.getAsNode("/contractTemplates/embeddedBridge")); + assertNotNull(current.getAsNode("/contractTemplates/embeddedChatCounter")); + assertFalse(current.getProperties().containsKey("embeddedTemplates")); + + for (int i = 1; i <= EMBEDDED_PARTICIPANTS; i++) { + // Alice creates /embedded_i plus the root contracts that make this new document routable: + // a simple timeline channel, an embedded-node bridge, a chat counter workflow, and a + // composite-channel entry. + DocumentProcessingResult result = support.blue.processDocument(current, + operationEvent(support, "alice", i, "createEmbedded")); + assertFalse(result.capabilityFailure(), result.failureReason()); + current = result.document(); + } + + assertEquals(BigInteger.valueOf(EMBEDDED_PARTICIPANTS), current.get("/nextEmbeddedNumber")); + for (int i = 1; i <= EMBEDDED_PARTICIPANTS; i++) { + assertEmbeddedParticipant(current, i); + assertEquals("/embedded_" + i, current.get("/contracts/embeddedDocs/paths/" + (i - 1))); + assertEquals("embedded_" + i + "_timeline", + current.get("/contracts/allEmbeddedTimelines/channels/" + (i - 1))); + assertNotNull(current.getAsNode("/contracts/embedded_" + i + "_timeline")); + assertNotNull(current.getAsNode("/contracts/embedded_" + i + "_bridge")); + assertNotNull(current.getAsNode("/contracts/embedded_" + i + "_chatCounter")); + } + + for (int i = 0; i < CHAT_MESSAGES; i++) { + int participantNumber = i + 1; + int timestamp = 10 + i; + // The generated embedded participant calls its own say operation. That operation lives + // inside /embedded_i and emits a chat message from the child document scope. + DocumentProcessingResult chatResult = support.blue.processDocument(current, + operationEvent(support, "embedded-" + participantNumber, timestamp, "say")); + assertFalse(chatResult.capabilityFailure(), chatResult.failureReason()); + current = chatResult.document(); + + // Bob checks the root counter after each embedded chat. The check is intentionally a + // separate operation so the test proves both automatic event counting and explicit user + // operations can interact with the same state. + DocumentProcessingResult bobCheck = support.blue.processDocument(current, + operationEvent(support, "bob", 100 + i, "checkChatCount")); + assertFalse(bobCheck.capabilityFailure(), bobCheck.failureReason()); + current = bobCheck.document(); + + assertEquals(BigInteger.valueOf(i + 1), current.get("/chatMessagesSeen")); + assertEquals(BigInteger.valueOf(i + 1), current.get("/embeddedTimelineEventsSeen")); + assertEquals(Boolean.valueOf(i + 1 >= 5), current.get("/success")); + } + + assertEquals(Boolean.TRUE, current.get("/success")); + long expectedPatchApplications = EMBEDDED_PARTICIPANTS + (CHAT_MESSAGES * 3L); + assertEquals(expectedPatchApplications, metrics.directBexChangesetHits(), + "Every returned Compute changeset should use direct BEX changeset application"); + assertEquals(expectedPatchApplications, metrics.updateBatchPatchApplications(), + "Alice creates, composite timeline counters, bridged chat counters, and Bob checks should batch apply"); + assertEquals(0L, metrics.updateIndividualPatchApplications()); + } + + private static void assertEmbeddedParticipant(Node document, int number) { + String prefix = "/embedded_" + number; + assertEquals("Embedded", document.get(prefix + "/name")); + assertEquals("Embedded " + number, document.get(prefix + "/displayName")); + assertEquals("embedded-" + number, + document.get(prefix + "/contracts/participantChannel/timelineId")); + assertNotNull(document.getAsNode(prefix + "/contracts/say")); + assertNotNull(document.getAsNode(prefix + "/contracts/sayImpl")); + } + + private static Node operationEvent(ComputeWorkflowTestSupport support, + String timelineId, + int timestamp, + String operation) { + return support.operationRequest( + timelineId, + timestamp, + operation, + new Node()); + } + +} diff --git a/src/test/java/blue/coordination/processor/compute/Ed25519IntrinsicWorkflowTest.java b/src/test/java/blue/coordination/processor/compute/Ed25519IntrinsicWorkflowTest.java new file mode 100644 index 0000000..aaaf2ee --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/Ed25519IntrinsicWorkflowTest.java @@ -0,0 +1,104 @@ +package blue.coordination.processor.compute; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.CoordinationBexIntrinsics; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class Ed25519IntrinsicWorkflowTest { + private static final String HOTEL_DOCUMENT = "coordination/compute/ed25519-hotel-access.yaml"; + private static final String THRESHOLD_DOCUMENT = "coordination/compute/ed25519-threshold-approval.yaml"; + + private static final String HOTEL_SIGNATURE = + "oVqYjDGViWObQDdAAyiwfauZqPIt3PwzJTbdt2VbS6hdSquw8GqQyTFE-9RUf3ubupP_h35sLlZPXulrPmeeBQ"; + private static final String ALICE_SIGNATURE = + "r2xDuEqbtGNwiDOULGp6Epc1g3L_59k-tVbh7_qnLKd1XiVkjeVCm2b7b-6HdRRATK6S0zpc_DnInvFdV3BOCw"; + private static final String BOB_SIGNATURE = + "3EXsrtb4nLC37E14iOsREFhFgibnIl6MyYjzAztnUfpNdicSqs3lj4RTHM0N9E8uNCPufItDDxkL4Q8dzem3DQ"; + + @Test + void hotelAccessUsesCommonEd25519IntrinsicToGrantValidSignedRequest() { + ComputeWorkflowTestSupport support = supportWithCommonIntrinsics(); + Node document = support.initialize(support.yamlResource(HOTEL_DOCUMENT)).document(); + + DocumentProcessingResult result = support.process(document, + support.operationRequest("hotel", 1, "checkIn", hotelRequest())); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertEquals(Boolean.TRUE, result.document().get("/usedNonces/customerA/hotel-nonce-1")); + assertEquals("Hotel Access Granted", onlyEvent(result).get("/kind")); + assertEquals("customerA", onlyEvent(result).get("/userId")); + assertEquals("R123", onlyEvent(result).get("/reservationId")); + } + + @Test + void thresholdApprovalExecutesActionAfterTwoValidEd25519Approvals() { + ComputeWorkflowTestSupport support = supportWithCommonIntrinsics(); + Node document = support.initialize(support.yamlResource(THRESHOLD_DOCUMENT)).document(); + + DocumentProcessingResult afterAlice = support.process(document, + support.operationRequest("admin", 1, "approveAction", + approvalRequest("alice", "alice-nonce-1", ALICE_SIGNATURE))); + + assertFalse(afterAlice.capabilityFailure(), afterAlice.failureReason()); + assertEquals("Admin Approval Recorded", onlyEvent(afterAlice).get("/kind")); + assertEquals(Boolean.TRUE, afterAlice.document().get("/approvals/delete-file-123/alice")); + + DocumentProcessingResult afterBob = support.process(afterAlice.document(), + support.operationRequest("admin", 2, "approveAction", + approvalRequest("bob", "bob-nonce-1", BOB_SIGNATURE))); + + assertFalse(afterBob.capabilityFailure(), afterBob.failureReason()); + assertEquals("Admin Action Executed", onlyEvent(afterBob).get("/kind")); + assertEquals(Boolean.TRUE, afterBob.document().get("/approvals/delete-file-123/alice")); + assertEquals(Boolean.TRUE, afterBob.document().get("/approvals/delete-file-123/bob")); + assertEquals(Boolean.TRUE, afterBob.document().get("/executed/delete-file-123")); + } + + private static ComputeWorkflowTestSupport supportWithCommonIntrinsics() { + BexEngine engine = BexEngine.builder() + .intrinsics(CoordinationBexIntrinsics.common()) + .build(); + return ComputeWorkflowTestSupport.create(CoordinationProcessorOptions.builder() + .bexEngine(engine) + .build()); + } + + private static Node hotelRequest() { + return object( + "userId", "customerA", + "reservationId", "R123", + "nonce", "hotel-nonce-1", + "expires", 1000, + "signature", HOTEL_SIGNATURE); + } + + private static Node approvalRequest(String signer, String nonce, String signature) { + return object( + "actionId", "delete-file-123", + "action", "delete-file", + "resource", "file123", + "signer", signer, + "nonce", nonce, + "expires", 1000, + "signature", signature); + } + + private static Node object(Object... fields) { + Node node = new Node(); + for (int i = 0; i < fields.length; i += 2) { + node.properties(String.valueOf(fields[i]), new Node().value(fields[i + 1])); + } + return node; + } + + private static Node onlyEvent(DocumentProcessingResult result) { + assertEquals(1, result.triggeredEvents().size()); + return result.triggeredEvents().get(0); + } +} diff --git a/src/test/java/blue/coordination/processor/compute/OfferPaynoteEmbeddedOrdersWorkflowTest.java b/src/test/java/blue/coordination/processor/compute/OfferPaynoteEmbeddedOrdersWorkflowTest.java new file mode 100644 index 0000000..4df2b5c --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/OfferPaynoteEmbeddedOrdersWorkflowTest.java @@ -0,0 +1,802 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import java.math.BigInteger; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Scenario: + * A package order for Customer and Travel Agency sells a 20-21 June weekend package: Deluxe Room in + * Hotel Badura plus a 250zl Dinner for Two at Restaurant Cud Malina for 499 PLN. + * + * Main flow: + * 1. Travel Agency delivers the exact Package PayNote as the operation request. + * 2. Card Processor authorizes the embedded PayNote. + * 3. Travel Agency provides the Restaurant Order and Hotel Order as separate operation requests inside + * the embedded PayNote. The orders are not root templates. + * 4. Restaurant and Hotel confirm their own embedded orders. + * 5. PayNote listens to the embedded order channels and requests capture only after both confirmations. + * 6. Card Processor confirms capture. + * 7. The package order listens to the embedded PayNote and changes order status to {@code Ready to use}. + * + * Actors and operations: + * - Customer and Travel Agency are package-order participants. + * - Travel Agency calls {@code deliverPaynote}, {@code provideRestaurantOrder}, and + * {@code provideHotelOrder}. + * - Card Processor calls {@code confirmAuthorization} and {@code confirmCapture}. + * - Restaurant and Hotel each call {@code confirm} inside their embedded order scopes. + */ +class OfferPaynoteEmbeddedOrdersWorkflowTest { + private static final String DOCUMENT_RESOURCE = + "coordination/compute/offer-paynote-embedded-orders-bex.yaml"; + + @Test + void packageOrderBecomesReadyToUseAfterPaynoteCapturesConfirmedRestaurantAndHotelOrders() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + + Node authored = support.yamlResource(DOCUMENT_RESOURCE); + assertNoRootTemplates(authored); + Node current = support.initialize(authored).document(); + assertEquals("Awaiting PayNote", current.get("/order/status")); + assertEquals("20-21 June weekend", current.get("/package/title")); + assertEquals("Deluxe Room", current.get("/package/roomType")); + assertEquals("Restaurant Cud Malina", current.get("/package/restaurantName")); + assertEquals(BigInteger.valueOf(499), current.get("/package/price/amount")); + long snapshotBuildsAfterInitialize = metrics.processingSnapshotFromDocumentBuilds(); + + // Travel Agency delivers the PayNote directly in the operation request. The root order embeds + // that request at /paynote and asks Card Processor to authorize 499 PLN. + DocumentProcessingResult paynoteDelivered = processMeasured(metrics, "deliverPaynote", support, current, + operationEvent(support, "travel-agency", 1, "deliverPaynote", packagePaynote(support))); + assertFalse(paynoteDelivered.capabilityFailure(), paynoteDelivered.failureReason()); + current = paynoteDelivered.document(); + assertEquals("Waiting for PayNote capture", current.get("/order/status")); + assertEquals(Boolean.TRUE, current.get("/order/paynoteDelivered")); + assertEquals("Package PayNote", current.get("/paynote/name")); + assertEquals("/paynote", current.get("/contracts/embeddedPaynotes/paths/0")); + assertContainsEventKind(paynoteDelivered.triggeredEvents(), "PayNote Authorization Requested"); + + // Card Processor authorizes the PayNote. Before this point, component orders are illegal. + DocumentProcessingResult authorized = processMeasured(metrics, "confirmAuthorization", support, current, + operationEvent(support, "card-processor", 2, "confirmAuthorization", new Node())); + assertFalse(authorized.capabilityFailure(), authorized.failureReason()); + current = authorized.document(); + assertEquals("Authorized", current.get("/paynote/status")); + + // Travel Agency provides the restaurant document as a request to PayNote. + DocumentProcessingResult restaurantProvided = processMeasured(metrics, "provideRestaurantOrder", support, current, + operationEvent(support, "travel-agency", 3, "provideRestaurantOrder", restaurantOrder(support))); + assertFalse(restaurantProvided.capabilityFailure(), restaurantProvided.failureReason()); + current = restaurantProvided.document(); + assertEquals("Restaurant Order", current.get("/paynote/restaurantOrder/name")); + assertEquals(Boolean.TRUE, current.get("/paynote/restaurantOrderProvided")); + assertEquals("/restaurantOrder", current.get("/paynote/contracts/componentOrders/paths/0")); + + // Travel Agency provides the hotel document as a separate request to PayNote. + DocumentProcessingResult hotelProvided = processMeasured(metrics, "provideHotelOrder", support, current, + operationEvent(support, "travel-agency", 4, "provideHotelOrder", hotelOrder(support))); + assertFalse(hotelProvided.capabilityFailure(), hotelProvided.failureReason()); + current = hotelProvided.document(); + assertEquals("Hotel Order", current.get("/paynote/hotelOrder/name")); + assertEquals(Boolean.TRUE, current.get("/paynote/hotelOrderProvided")); + assertEquals("/hotelOrder", current.get("/paynote/contracts/componentOrders/paths/1")); + + // Restaurant confirms the restaurant order. PayNote notices the embedded event, but capture + // is still blocked because the hotel order has not confirmed yet. + DocumentProcessingResult restaurantConfirmed = processMeasured(metrics, "restaurantConfirm", support, current, + operationEvent(support, "restaurant", 5, "confirm", new Node())); + assertFalse(restaurantConfirmed.capabilityFailure(), restaurantConfirmed.failureReason()); + current = restaurantConfirmed.document(); + assertEquals("Confirmed", current.get("/paynote/restaurantOrder/status")); + assertEquals(Boolean.TRUE, current.get("/paynote/restaurantConfirmed")); + assertEquals(Boolean.FALSE, current.get("/paynote/captureRequested")); + + // Hotel confirms the hotel order. Now both embedded confirmations exist, so PayNote emits a + // capture request for Card Processor. + DocumentProcessingResult hotelConfirmed = processMeasured(metrics, "hotelConfirm", support, current, + operationEvent(support, "hotel", 6, "confirm", new Node())); + assertFalse(hotelConfirmed.capabilityFailure(), hotelConfirmed.failureReason()); + current = hotelConfirmed.document(); + assertEquals("Confirmed", current.get("/paynote/hotelOrder/status")); + assertEquals(Boolean.TRUE, current.get("/paynote/hotelConfirmed")); + assertEquals(Boolean.TRUE, current.get("/paynote/captureRequested")); + + // Card Processor confirms capture. The root package order observes /paynote/captured through + // a Document Update Channel and switches to Ready to use. + DocumentProcessingResult captured = processMeasured(metrics, "confirmCapture", support, current, + operationEvent(support, "card-processor", 7, "confirmCapture", new Node())); + assertFalse(captured.capabilityFailure(), captured.failureReason()); + current = captured.document(); + assertEquals("Captured", current.get("/paynote/status")); + assertEquals(Boolean.TRUE, current.get("/paynote/captured")); + assertEquals("Ready to use", current.get("/order/status")); + assertContainsEventKind(captured.triggeredEvents(), "Package Order Ready to Use"); + + assertEquals(0L, metrics.updateIndividualPatchApplications()); + assertEquals(metrics.updateBatchPatchApplications(), metrics.directBexChangesetHits()); + assertEquals(0L, metrics.bexDocumentViewMaterializedHits()); + assertEquals(0L, metrics.bexSyntheticProgramMaterializations()); + assertEquals(0L, metrics.workflowDocumentViewsFromDocument()); + assertEquals(0L, metrics.workflowDocumentViewMisses()); + assertTrue(metrics.bexDocumentViewFrozenDirectHits() > 0L); + assertEquals(snapshotBuildsAfterInitialize, metrics.processingSnapshotFromDocumentBuilds()); + } + + @Test + void illegalPackagePaynoteAndComponentOrderOperationsFailClosed() { + ComputeWorkflowTestSupport support = support(null); + Node current = support.initialize(support.yamlResource(DOCUMENT_RESOURCE)).document(); + + // Illegal: wrong PayNote amount. The package order only accepts the exact 499 PLN PayNote for + // this Hotel Badura + Cud Malina weekend package. This is rejected by deliverPaynote.request + // matching, so the workflow does not run and the document is unchanged. + Node wrongPaynote = packagePaynote(support); + wrongPaynote.getProperties().put("amount", new Node().value(498)); + DocumentProcessingResult wrongPaynoteResult = support.blue.processDocument(current, + operationEvent(support, "travel-agency", 11, "deliverPaynote", wrongPaynote)); + assertFalse(wrongPaynoteResult.capabilityFailure(), wrongPaynoteResult.failureReason()); + assertFalse(wrongPaynoteResult.document().getProperties().containsKey("paynote")); + assertEquals("Awaiting PayNote", wrongPaynoteResult.document().get("/order/status")); + + current = support.blue.processDocument(current, + operationEvent(support, "travel-agency", 12, "deliverPaynote", packagePaynote(support))).document(); + + // Illegal: Travel Agency cannot provide component orders until Card Processor authorizes the + // embedded PayNote. + Node pendingAuthorization = current; + DocumentProcessingResult beforeAuthorization = support.blue.processDocument(pendingAuthorization, + operationEvent(support, "travel-agency", 13, "provideHotelOrder", hotelOrder(support))); + assertRuntimeFatal(beforeAuthorization, "after PayNote authorization"); + + current = support.blue.processDocument(current, + operationEvent(support, "card-processor", 14, "confirmAuthorization", new Node())).document(); + + // Illegal: provideRestaurantOrder rejects a hotel document at operation-request matching time. + // Restaurant and hotel fulfillment documents are intentionally specific and not interchangeable. + DocumentProcessingResult wrongRestaurantDocument = support.blue.processDocument(current, + operationEvent(support, "travel-agency", 15, "provideRestaurantOrder", hotelOrder(support))); + assertFalse(wrongRestaurantDocument.capabilityFailure(), wrongRestaurantDocument.failureReason()); + assertFalse(wrongRestaurantDocument.document().getAsNode("/paynote").getProperties() + .containsKey("restaurantOrder")); + assertEquals(Boolean.FALSE, wrongRestaurantDocument.document().get("/paynote/restaurantOrderProvided")); + + current = support.blue.processDocument(current, + operationEvent(support, "travel-agency", 16, "provideRestaurantOrder", restaurantOrder(support))).document(); + current = support.blue.processDocument(current, + operationEvent(support, "travel-agency", 17, "provideHotelOrder", hotelOrder(support))).document(); + + // Illegal: Card Processor cannot capture before both Restaurant and Hotel have confirmed. + Node beforeCaptureRequested = current; + DocumentProcessingResult earlyCapture = support.blue.processDocument(beforeCaptureRequested, + operationEvent(support, "card-processor", 18, "confirmCapture", new Node())); + assertRuntimeFatal(earlyCapture, "before both orders confirm"); + } + + private static ComputeWorkflowTestSupport support(BexProcessingMetrics metrics) { + CoordinationProcessorOptions.Builder builder = CoordinationProcessorOptions.builder(); + if (metrics != null) { + builder.processingMetrics(metrics); + } + return ComputeWorkflowTestSupport.create(builder.build()); + } + + private static DocumentProcessingResult processMeasured(BexProcessingMetrics metrics, + String label, + ComputeWorkflowTestSupport support, + Node document, + Node event) { + BexProcessingMetrics.Snapshot before = metrics.snapshot(); + long start = System.nanoTime(); + DocumentProcessingResult result = support.blue.processDocument(document, event); + long wallNanos = System.nanoTime() - start; + BexProcessingMetrics.Snapshot after = metrics.snapshot(); + printStepMetrics(label, wallNanos, result, before, after); + return result; + } + + private static void printStepMetrics(String label, + long wallNanos, + DocumentProcessingResult result, + BexProcessingMetrics.Snapshot before, + BexProcessingMetrics.Snapshot after) { + System.out.printf(Locale.ROOT, + "[offer-paynote metrics] %s wall=%.3fms status=%s gas=%d events=%d snapshot=%s failure=%s%n", + label, + nanosToMs(wallNanos), + result.status(), + result.totalGas(), + result.triggeredEvents().size(), + result.snapshot() != null, + result.failureReason()); + System.out.printf(Locale.ROOT, + " processor blue=%.3fms process=%.3fms preprocess=%.3fms bundle=%.3fms actualBundle=%.3fms reuse=%.3fms cacheKey=%.3fms bundleHits=%d bundleMisses=%d built=%d reused=%d%n", + ms(after.blueProcessDocumentNanos, before.blueProcessDocumentNanos), + ms(after.processDocumentNanos, before.processDocumentNanos), + ms(after.eventPreprocessNanos, before.eventPreprocessNanos), + ms(after.bundleLoadNanos, before.bundleLoadNanos), + ms(after.bundleLoadActualBuildNanos, before.bundleLoadActualBuildNanos), + ms(after.bundleLoadReuseNanos, before.bundleLoadReuseNanos), + ms(after.bundleLoadCacheKeyBuildNanos, before.bundleLoadCacheKeyBuildNanos), + delta(after.bundleLoadCacheHits, before.bundleLoadCacheHits), + delta(after.bundleLoadCacheMisses, before.bundleLoadCacheMisses), + delta(after.bundlesBuilt, before.bundlesBuilt), + delta(after.bundlesReused, before.bundlesReused)); + System.out.printf(Locale.ROOT, + " snapshotCache lookup=%.3fms hits=%d misses=%d fromDocument=%.3fms builds=%d bundleScope attempts=%d execHits=%d refreshes=%d termination=%.3fms resolved=%.3fms contractLoad=%.3fms%n", + ms(after.processingSnapshotCacheLookupNanos, before.processingSnapshotCacheLookupNanos), + delta(after.processingSnapshotCacheHits, before.processingSnapshotCacheHits), + delta(after.processingSnapshotCacheMisses, before.processingSnapshotCacheMisses), + ms(after.processingSnapshotFromDocumentNanos, before.processingSnapshotFromDocumentNanos), + delta(after.processingSnapshotFromDocumentBuilds, before.processingSnapshotFromDocumentBuilds), + delta(after.bundleScopeLoadAttempts, before.bundleScopeLoadAttempts), + delta(after.bundleScopeExecutionCacheHits, before.bundleScopeExecutionCacheHits), + delta(after.bundleScopeRefreshes, before.bundleScopeRefreshes), + ms(after.bundleScopeTerminationCheckNanos, before.bundleScopeTerminationCheckNanos), + ms(after.bundleScopeResolvedLookupNanos, before.bundleScopeResolvedLookupNanos), + ms(after.bundleScopeContractLoadNanos, before.bundleScopeContractLoadNanos)); + System.out.printf(Locale.ROOT, + " routing channelDiscovery=%.3fms channelMatch=%.3fms channelEvals=%d handlerDiscovery=%.3fms handlerMatch=%.3fms handlerAttempts=%d handlerExecution=%.3fms handlers=%d eventRouting=%.3fms routed=%d%n", + ms(after.channelDiscoveryNanos, before.channelDiscoveryNanos), + ms(after.channelMatchNanos, before.channelMatchNanos), + delta(after.channelEvaluations, before.channelEvaluations), + ms(after.handlerDiscoveryNanos, before.handlerDiscoveryNanos), + ms(after.handlerMatchNanos, before.handlerMatchNanos), + delta(after.handlerMatchAttempts, before.handlerMatchAttempts), + ms(after.handlerExecutionNanos, before.handlerExecutionNanos), + delta(after.handlersExecuted, before.handlersExecuted), + ms(after.triggeredEventRoutingNanos, before.triggeredEventRoutingNanos), + delta(after.triggeredEventsRouted, before.triggeredEventsRouted)); + System.out.printf(Locale.ROOT, + " workflow runner=%.3fms steps=%d computeSteps=%d updateSteps=%d triggerSteps=%d compute=%.3fms update=%.3fms trigger=%.3fms checkpoint=%.3fms snapshot=%.3fms post=%.3fms%n", + ms(after.workflowRunnerNanos, before.workflowRunnerNanos), + delta(after.workflowStepsExecuted, before.workflowStepsExecuted), + delta(after.computeStepsExecuted, before.computeStepsExecuted), + delta(after.updateDocumentStepsExecuted, before.updateDocumentStepsExecuted), + delta(after.triggerEventStepsExecuted, before.triggerEventStepsExecuted), + ms(after.computeStepNanos, before.computeStepNanos), + ms(after.updateStepNanos, before.updateStepNanos), + ms(after.triggerStepNanos, before.triggerStepNanos), + ms(after.checkpointUpdateNanos, before.checkpointUpdateNanos), + ms(after.snapshotCommitNanos, before.snapshotCommitNanos), + ms(after.postProcessingNanos, before.postProcessingNanos)); + System.out.printf(Locale.ROOT, + " checkpoint phases ensure=%.3fms find=%.3fms currentIdentity=%.3fms isNewer=%.3fms duplicate=%.3fms persist=%.3fms identityCache hits=%d misses=%d storedHits=%d storedMisses=%d directBlueId=%.3fms contentBlueId=%.3fms fallback=%.3fms%n", + ms(after.checkpointEnsureNanos, before.checkpointEnsureNanos), + ms(after.checkpointFindNanos, before.checkpointFindNanos), + ms(after.checkpointCurrentIdentityNanos, before.checkpointCurrentIdentityNanos), + ms(after.checkpointIsNewerNanos, before.checkpointIsNewerNanos), + ms(after.checkpointDuplicateNanos, before.checkpointDuplicateNanos), + ms(after.checkpointPersistNanos, before.checkpointPersistNanos), + delta(after.checkpointIdentityCacheHits, before.checkpointIdentityCacheHits), + delta(after.checkpointIdentityCacheMisses, before.checkpointIdentityCacheMisses), + delta(after.checkpointStoredIdentityCacheHits, before.checkpointStoredIdentityCacheHits), + delta(after.checkpointStoredIdentityCacheMisses, before.checkpointStoredIdentityCacheMisses), + ms(after.checkpointDirectBlueIdNanos, before.checkpointDirectBlueIdNanos), + ms(after.checkpointContentBlueIdNanos, before.checkpointContentBlueIdNanos), + ms(after.checkpointFallbackNanos, before.checkpointFallbackNanos)); + System.out.printf(Locale.ROOT, + " bex compileExecute=%.3fms compile=%.3fms execute=%.3fms compiled=%d cacheHits=%d cacheMisses=%d nodeWriter=%.3fms syntheticProgramMaterializations=%d directChangesets=%d%n", + ms(after.computeCompileExecuteNanos, before.computeCompileExecuteNanos), + ms(after.bexCompileNanos, before.bexCompileNanos), + ms(after.bexExecuteNanos, before.bexExecuteNanos), + delta(after.bexCompiledExecutions, before.bexCompiledExecutions), + delta(after.bexCompileCacheHits, before.bexCompileCacheHits), + delta(after.bexCompileCacheMisses, before.bexCompileCacheMisses), + ms(after.bexNodeWriterNanos, before.bexNodeWriterNanos), + delta(after.bexSyntheticProgramMaterializations, before.bexSyntheticProgramMaterializations), + delta(after.directBexChangesetHits, before.directBexChangesetHits)); + System.out.printf(Locale.ROOT, + " patches applied=%d batch=%d individual=%d conversion=%.3fms apply=%.3fms batchPlan=%.3fms batchConform=%.3fms batchBuild=%.3fms batchCommit=%.3fms boundary=%.3fms gas=%.3fms updateRouting=%.3fms%n", + delta(after.patchesApplied, before.patchesApplied), + delta(after.updateBatchPatchApplications, before.updateBatchPatchApplications), + delta(after.updateIndividualPatchApplications, before.updateIndividualPatchApplications), + ms(after.updatePatchConversionNanos, before.updatePatchConversionNanos), + ms(after.updatePatchApplyNanos, before.updatePatchApplyNanos), + ms(after.batchPatchPlanningNanos, before.batchPatchPlanningNanos), + ms(after.batchPatchConformanceNanos, before.batchPatchConformanceNanos), + ms(after.batchPatchBuildUpdatesNanos, before.batchPatchBuildUpdatesNanos), + ms(after.batchPatchCommitNanos, before.batchPatchCommitNanos), + ms(after.patchBoundaryNanos, before.patchBoundaryNanos), + ms(after.patchGasNanos, before.patchGasNanos), + ms(after.documentUpdateRoutingNanos, before.documentUpdateRoutingNanos)); + System.out.printf(Locale.ROOT, + " documentView workflowFromFrozen=%d workflowFromDocument=%d workflowMisses=%d bexMaterialized=%d frozenDirect=%d frozenRootFallback=%d undefined=%d updateMaterializeBefore=%d updateMaterializeAfter=%d%n", + delta(after.workflowDocumentViewsFromFrozen, before.workflowDocumentViewsFromFrozen), + delta(after.workflowDocumentViewsFromDocument, before.workflowDocumentViewsFromDocument), + delta(after.workflowDocumentViewMisses, before.workflowDocumentViewMisses), + delta(after.bexDocumentViewMaterializedHits, before.bexDocumentViewMaterializedHits), + delta(after.bexDocumentViewFrozenDirectHits, before.bexDocumentViewFrozenDirectHits), + delta(after.bexDocumentViewFrozenRootFallbackHits, before.bexDocumentViewFrozenRootFallbackHits), + delta(after.bexDocumentViewUndefinedHits, before.bexDocumentViewUndefinedHits), + delta(after.documentUpdateBeforeMaterializations, before.documentUpdateBeforeMaterializations), + delta(after.documentUpdateAfterMaterializations, before.documentUpdateAfterMaterializations)); + } + + private static long delta(long after, long before) { + return after - before; + } + + private static double ms(long afterNanos, long beforeNanos) { + return nanosToMs(afterNanos - beforeNanos); + } + + private static double nanosToMs(long nanos) { + return nanos / 1_000_000.0d; + } + + private static void assertNoRootTemplates(Node document) { + assertFalse(document.getProperties().containsKey("paynoteTemplate")); + assertFalse(document.getProperties().containsKey("hotelOrderTemplate")); + assertFalse(document.getProperties().containsKey("restaurantOrderTemplate")); + } + + private static Node operationEvent(ComputeWorkflowTestSupport support, + String timelineId, + int timestamp, + String operation, + Node request) { + return support.operationRequest(timelineId, timestamp, operation, request); + } + + private static Node packagePaynote(ComputeWorkflowTestSupport support) { + return support.yaml(String.join("\n", + "name: Package PayNote", + "packageId: weekend-badura-cud-malina", + "status: Pending authorization", + "amount: 499", + "currency: PLN", + "startDate: 2026-06-20", + "endDate: 2026-06-21", + "customer: Customer", + "travelAgency: Travel Agency", + "cardProcessor: Card Processor", + "restaurantOrderProvided: false", + "hotelOrderProvided: false", + "restaurantConfirmed: false", + "hotelConfirmed: false", + "captureRequested: false", + "captured: false", + "contracts:", + " travelAgencyChannel:", + " type: Coordination/Timeline Channel", + " timelineId: travel-agency", + " cardProcessorChannel:", + " type: Coordination/Timeline Channel", + " timelineId: card-processor", + " confirmAuthorization:", + " type: Coordination/Operation", + " channel: cardProcessorChannel", + " confirmAuthorizationImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: confirmAuthorization", + " steps:", + " - name: BuildAuthorizationPatch", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $ne:", + " - $document: /status", + " - Pending authorization", + " then:", + " - $fail: PayNote authorization can only be confirmed while pending", + " - $appendChange:", + " op: replace", + " path: /status", + " val: Authorized", + " - $appendEvent:", + " type: Coordination/Event", + " kind: PayNote Authorized", + " amount:", + " $document: /amount", + " currency:", + " $document: /currency", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " provideRestaurantOrder:", + " type: Coordination/Operation", + " channel: travelAgencyChannel", + " request:", + " name: Restaurant Order", + " packageId: weekend-badura-cud-malina", + " restaurantName: Restaurant Cud Malina", + " description: 250zl Dinner for Two", + " dinnerDate: 2026-06-20", + " amount: 250", + " currency: PLN", + " status: Pending", + " contracts:", + " restaurantChannel:", + " timelineId: restaurant", + " confirm:", + " channel: restaurantChannel", + " confirmImpl:", + " operation: confirm", + " provideRestaurantOrderImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: provideRestaurantOrder", + " steps:", + " - name: BuildRestaurantOrderPatch", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $ne:", + " - $document: /status", + " - Authorized", + " then:", + " - $fail: Restaurant Order can only be provided after PayNote authorization", + " - $if:", + " cond:", + " $document: /restaurantOrderProvided", + " then:", + " - $fail: Restaurant Order is already provided", + " - $appendChange:", + " op: add", + " path: /restaurantOrder", + " val:", + " $binding:", + " name: event", + " path: /message/request", + " - $appendChange:", + " op: replace", + " path: /restaurantOrderProvided", + " val: true", + " - $appendChange:", + " op: add", + " path: /contracts/componentOrders/paths/-", + " val: /restaurantOrder", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " provideHotelOrder:", + " type: Coordination/Operation", + " channel: travelAgencyChannel", + " request:", + " name: Hotel Order", + " packageId: weekend-badura-cud-malina", + " hotelName: Hotel Badura", + " roomType: Deluxe Room", + " checkIn: 2026-06-20", + " checkOut: 2026-06-21", + " amount: 249", + " currency: PLN", + " status: Pending", + " contracts:", + " hotelChannel:", + " timelineId: hotel", + " confirm:", + " channel: hotelChannel", + " confirmImpl:", + " operation: confirm", + " provideHotelOrderImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: provideHotelOrder", + " steps:", + " - name: BuildHotelOrderPatch", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $ne:", + " - $document: /status", + " - Authorized", + " then:", + " - $fail: Hotel Order can only be provided after PayNote authorization", + " - $if:", + " cond:", + " $document: /hotelOrderProvided", + " then:", + " - $fail: Hotel Order is already provided", + " - $appendChange:", + " op: add", + " path: /hotelOrder", + " val:", + " $binding:", + " name: event", + " path: /message/request", + " - $appendChange:", + " op: replace", + " path: /hotelOrderProvided", + " val: true", + " - $appendChange:", + " op: add", + " path: /contracts/componentOrders/paths/-", + " val: /hotelOrder", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " componentOrders:", + " type: Process Embedded", + " paths: []", + " restaurantOrderEvents:", + " type: Embedded Node Channel", + " childPath: /restaurantOrder", + " hotelOrderEvents:", + " type: Embedded Node Channel", + " childPath: /hotelOrder", + " restaurantOrderConfirmed:", + " type: Coordination/Sequential Workflow", + " channel: restaurantOrderEvents", + " event:", + " type: Coordination/Event", + " kind: Component Order Confirmed", + " component: restaurant", + " steps:", + " - name: BuildRestaurantConfirmedPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /restaurantConfirmed", + " val: true", + " - $if:", + " cond:", + " $and:", + " - $document: /hotelConfirmed", + " - $not:", + " $document: /captureRequested", + " then:", + " - $appendChange:", + " op: replace", + " path: /captureRequested", + " val: true", + " - $appendEvent:", + " type: Coordination/Event", + " kind: PayNote Capture Requested", + " amount:", + " $document: /amount", + " currency:", + " $document: /currency", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " hotelOrderConfirmed:", + " type: Coordination/Sequential Workflow", + " channel: hotelOrderEvents", + " event:", + " type: Coordination/Event", + " kind: Component Order Confirmed", + " component: hotel", + " steps:", + " - name: BuildHotelConfirmedPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /hotelConfirmed", + " val: true", + " - $if:", + " cond:", + " $and:", + " - $document: /restaurantConfirmed", + " - $not:", + " $document: /captureRequested", + " then:", + " - $appendChange:", + " op: replace", + " path: /captureRequested", + " val: true", + " - $appendEvent:", + " type: Coordination/Event", + " kind: PayNote Capture Requested", + " amount:", + " $document: /amount", + " currency:", + " $document: /currency", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " confirmCapture:", + " type: Coordination/Operation", + " channel: cardProcessorChannel", + " confirmCaptureImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: confirmCapture", + " steps:", + " - name: BuildCapturePatch", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $not:", + " $document: /captureRequested", + " then:", + " - $fail: PayNote capture cannot be confirmed before both orders confirm", + " - $appendChange:", + " op: replace", + " path: /status", + " val: Captured", + " - $appendChange:", + " op: replace", + " path: /captured", + " val: true", + " - $appendEvent:", + " type: Coordination/Event", + " kind: PayNote Captured", + " amount:", + " $document: /amount", + " currency:", + " $document: /currency", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true")); + } + + private static Node restaurantOrder(ComputeWorkflowTestSupport support) { + return support.yaml(String.join("\n", + "name: Restaurant Order", + "packageId: weekend-badura-cud-malina", + "restaurantName: Restaurant Cud Malina", + "description: 250zl Dinner for Two", + "dinnerDate: 2026-06-20", + "amount: 250", + "currency: PLN", + "status: Pending", + "contracts:", + " restaurantChannel:", + " type: Coordination/Timeline Channel", + " timelineId: restaurant", + " confirm:", + " type: Coordination/Operation", + " channel: restaurantChannel", + " confirmImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: confirm", + " steps:", + " - name: BuildConfirmation", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $ne:", + " - $document: /status", + " - Pending", + " then:", + " - $fail: Restaurant Order can only be confirmed while pending", + " - $appendChange:", + " op: replace", + " path: /status", + " val: Confirmed", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Component Order Confirmed", + " component: restaurant", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true")); + } + + private static Node hotelOrder(ComputeWorkflowTestSupport support) { + return support.yaml(String.join("\n", + "name: Hotel Order", + "packageId: weekend-badura-cud-malina", + "hotelName: Hotel Badura", + "roomType: Deluxe Room", + "checkIn: 2026-06-20", + "checkOut: 2026-06-21", + "amount: 249", + "currency: PLN", + "status: Pending", + "contracts:", + " hotelChannel:", + " type: Coordination/Timeline Channel", + " timelineId: hotel", + " confirm:", + " type: Coordination/Operation", + " channel: hotelChannel", + " confirmImpl:", + " type: Coordination/Sequential Workflow Operation", + " operation: confirm", + " steps:", + " - name: BuildConfirmation", + " type: Coordination/Compute", + " do:", + " - $if:", + " cond:", + " $ne:", + " - $document: /status", + " - Pending", + " then:", + " - $fail: Hotel Order can only be confirmed while pending", + " - $appendChange:", + " op: replace", + " path: /status", + " val: Confirmed", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Component Order Confirmed", + " component: hotel", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true")); + } + + private static void assertContainsEventKind(List events, String expectedKind) { + for (Node event : events) { + if (expectedKind.equals(eventKind(event))) { + return; + } + } + throw new AssertionError("Expected event kind " + expectedKind + " in " + events); + } + + private static String eventKind(Node event) { + if (event == null) { + return null; + } + Node kind = event.getProperties() != null ? event.getProperties().get("kind") : null; + Object value = kind != null ? kind.getValue() : null; + return value instanceof String ? (String) value : null; + } + + private static void assertRuntimeFatal(DocumentProcessingResult result, String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + if (result.failureReason() != null && result.failureReason().contains(expectedMessage)) { + return; + } + assertTrue(containsStringValue(result.document(), expectedMessage), + "Expected fatal reason containing: " + expectedMessage); + } + + private static boolean containsStringValue(Node node, String expectedMessage) { + if (node == null) { + return false; + } + Object value = node.getValue(); + if (value instanceof String && ((String) value).contains(expectedMessage)) { + return true; + } + if (containsStringValue(node.getType(), expectedMessage)) { + return true; + } + if (containsStringValue(node.getContracts(), expectedMessage)) { + return true; + } + if (node.getItems() != null) { + for (Node item : node.getItems()) { + if (containsStringValue(item, expectedMessage)) { + return true; + } + } + } + if (node.getProperties() != null) { + for (Node property : node.getProperties().values()) { + if (containsStringValue(property, expectedMessage)) { + return true; + } + } + } + return false; + } + +} diff --git a/src/test/java/blue/coordination/processor/compute/PaynoteReducedDefinitionWorkflowTest.java b/src/test/java/blue/coordination/processor/compute/PaynoteReducedDefinitionWorkflowTest.java new file mode 100644 index 0000000..d34dac1 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/PaynoteReducedDefinitionWorkflowTest.java @@ -0,0 +1,696 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.TestTimelineProvider; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.repo.BlueRepository; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.List; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Scenario: + * A reduced Paynote resale package document exercises shared Compute Definitions, BEX field fast paths, + * batch patching, bundle caching, and cold/warm processing metrics. + * + * Main flow: + * 1. A hotel participant places a resale order through the hotel participant timeline. + * 2. The participant workflow forwards a subscription update event. + * 3. The package workflow catches that update, calls the hotel entry function from the shared + * {@code packageFulfillmentComputeDefinition}, and applies its returned changeset. + * 4. A restaurant participant repeats the same pattern through a different operation and different + * definition entry function. + * 5. Tests print cold, warm, same-path, and event-only timing so setup, compilation, bundle loading, + * handler matching, BEX execution, and patch application costs are visible. + * + * Actors and operations: + * - {@code hotel-participant} calls {@code hotelResaleOrderPlaced}. + * - {@code restaurant-participant} calls {@code restaurantResaleOrderPlaced}. + * - Both operations share one Compute Definition but enter different functions. + * - Compute returns changesets and events directly from BEX accumulators. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class PaynoteReducedDefinitionWorkflowTest { + private static final String DOCUMENT_RESOURCE = "/processor-delay/paynote-resale-reduced-bex.yaml"; + private static Fixture fixture; + private static BexProcessingMetrics metrics; + private static Node initializedDocument; + private static Node hotelEvent; + private static Node restaurantEvent; + private static double setupBlueMs; + private static double loadYamlMs; + private static double initializeMs; + private static double buildHotelEventMs; + private static double buildRestaurantEventMs; + + @BeforeAll + static void prepareFixture() { + long start = System.nanoTime(); + metrics = new BexProcessingMetrics(); + fixture = configuredFixture(metrics); + setupBlueMs = elapsedMs(start); + + start = System.nanoTime(); + Node document = loadYaml(fixture, DOCUMENT_RESOURCE); + loadYamlMs = elapsedMs(start); + + start = System.nanoTime(); + initializedDocument = fixture.blue.initializeDocument(document).document(); + initializeMs = elapsedMs(start); + + start = System.nanoTime(); + hotelEvent = participantOperation(fixture, + "hotel-participant", + 1700000100, + "hotelResaleOrderPlaced", + subscriptionUpdate("hotel-resale-agreement", + "hotel-agreement-session", + "hotel-request-a", + "hotel-order-session-a")); + buildHotelEventMs = elapsedMs(start); + + start = System.nanoTime(); + restaurantEvent = participantOperation(fixture, + "restaurant-participant", + 1700000200, + "restaurantResaleOrderPlaced", + subscriptionUpdate("restaurant-resale-agreement", + "restaurant-agreement-session", + "restaurant-request-a", + "restaurant-order-session-a")); + buildRestaurantEventMs = elapsedMs(start); + } + + @Test + @Order(1) + void eventProcessingOnlyTimingColdAndWarm() { + BexProcessingMetrics.Snapshot beforeCold = metrics.snapshot(); + long start = System.nanoTime(); + DocumentProcessingResult coldHotel = fixture.blue.processDocument(initializedDocument, hotelEvent); + double coldHotelMs = elapsedMs(start); + + start = System.nanoTime(); + DocumentProcessingResult coldRestaurant = fixture.blue.processDocument(coldHotel.document(), restaurantEvent); + double coldRestaurantMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterCold = metrics.snapshot(); + + assertFalse(coldHotel.capabilityFailure(), coldHotel.failureReason()); + assertFalse(coldRestaurant.capabilityFailure(), coldRestaurant.failureReason()); + assertEquals(Boolean.TRUE, coldRestaurant.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + assertEquals(Boolean.TRUE, coldRestaurant.document().get("/orders/package-order-a/restaurantOrder/resalePlaced")); + + start = System.nanoTime(); + DocumentProcessingResult warmHotel = fixture.blue.processDocument(initializedDocument, hotelEvent); + double warmHotelMs = elapsedMs(start); + + start = System.nanoTime(); + DocumentProcessingResult warmRestaurant = fixture.blue.processDocument(warmHotel.document(), restaurantEvent); + double warmRestaurantMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterWarm = metrics.snapshot(); + + assertFalse(warmHotel.capabilityFailure(), warmHotel.failureReason()); + assertFalse(warmRestaurant.capabilityFailure(), warmRestaurant.failureReason()); + assertEquals(Boolean.TRUE, warmRestaurant.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + assertEquals(Boolean.TRUE, warmRestaurant.document().get("/orders/package-order-a/restaurantOrder/resalePlaced")); + + System.out.printf(Locale.ROOT, + "Paynote reduced BEX cold/warm timing - coldHotelMs: %.3fms, coldRestaurantMs: %.3fms, " + + "warmHotelMs: %.3fms, warmRestaurantMs: %.3fms%n", + coldHotelMs, + coldRestaurantMs, + warmHotelMs, + warmRestaurantMs); + printMetricsDelta("cold event-only metrics delta", beforeCold, afterCold); + printMetricsDelta("warm event-only metrics delta", afterCold, afterWarm); + assertEquals(2L, afterCold.updateBatchPatchApplications - beforeCold.updateBatchPatchApplications); + assertEquals(0L, afterCold.updateIndividualPatchApplications - beforeCold.updateIndividualPatchApplications); + assertEquals(2L, afterWarm.updateBatchPatchApplications - afterCold.updateBatchPatchApplications); + assertEquals(0L, afterWarm.updateIndividualPatchApplications - afterCold.updateIndividualPatchApplications); + } + + @Test + @Order(2) + void twoParticipantsCallDifferentOperationsBackedBySharedComputeDefinition() { + long totalStart = System.nanoTime(); + BexProcessingMetrics.Snapshot before = metrics.snapshot(); + printSetupTimings(); + + long start = System.nanoTime(); + DocumentProcessingResult hotelResult = fixture.blue.processDocument(initializedDocument, hotelEvent); + printTiming("process hotel participant operation", start); + + assertFalse(hotelResult.capabilityFailure(), hotelResult.failureReason()); + assertEquals("placed", hotelResult.document().getAsText("/resaleOrderRequests/hotel-request-a/status")); + assertEquals("hotel-order-session-a", + hotelResult.document().getAsText("/resaleOrderRequests/hotel-request-a/orderSessionId")); + assertEquals(Boolean.TRUE, hotelResult.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + assertEquals("hotel-order-session-a", + hotelResult.document().getAsText("/orders/package-order-a/hotelOrder/sessionId")); + assertEquals("snapshot:component:hotel:hotel-order-session-a", + hotelResult.document().getAsText("/orders/package-order-a/hotelOrder/snapshotRequestId")); + assertEquals("agreement-linked:hotel:hotel-order-session-a", + hotelResult.document().getAsText("/orders/package-order-a/hotelOrder/subscriptionId")); + assertEquals("package-order-a", + hotelResult.document().getAsText("/componentOrderRefsBySessionId/hotel-order-session-a/packageOrderSessionId")); + assertEquals("hotelOrder", + hotelResult.document().getAsText("/componentOrderRefsBySessionId/hotel-order-session-a/component")); + assertContainsType(hotelResult.triggeredEvents(), "Sample/Document Initial Snapshot Requested"); + assertContainsType(hotelResult.triggeredEvents(), "Sample/Subscribe to Session Requested"); + + start = System.nanoTime(); + DocumentProcessingResult restaurantResult = fixture.blue.processDocument(hotelResult.document(), restaurantEvent); + printTiming("process restaurant participant operation", start); + + assertFalse(restaurantResult.capabilityFailure(), restaurantResult.failureReason()); + assertNotNull(restaurantResult.document()); + assertEquals("placed", restaurantResult.document().getAsText("/resaleOrderRequests/restaurant-request-a/status")); + assertEquals("restaurant-order-session-a", + restaurantResult.document().getAsText("/resaleOrderRequests/restaurant-request-a/orderSessionId")); + assertEquals(Boolean.TRUE, restaurantResult.document().get("/orders/package-order-a/restaurantOrder/resalePlaced")); + assertEquals("restaurant-order-session-a", + restaurantResult.document().getAsText("/orders/package-order-a/restaurantOrder/sessionId")); + assertEquals("snapshot:component:restaurant:restaurant-order-session-a", + restaurantResult.document().getAsText("/orders/package-order-a/restaurantOrder/snapshotRequestId")); + assertEquals("agreement-linked:restaurant:restaurant-order-session-a", + restaurantResult.document().getAsText("/orders/package-order-a/restaurantOrder/subscriptionId")); + assertEquals("package-order-a", + restaurantResult.document().getAsText("/componentOrderRefsBySessionId/restaurant-order-session-a/packageOrderSessionId")); + assertEquals("restaurantOrder", + restaurantResult.document().getAsText("/componentOrderRefsBySessionId/restaurant-order-session-a/component")); + assertEquals(Boolean.TRUE, restaurantResult.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + assertContainsType(restaurantResult.triggeredEvents(), "Sample/Document Initial Snapshot Requested"); + assertContainsType(restaurantResult.triggeredEvents(), "Sample/Subscribe to Session Requested"); + printTiming("total reduced paynote flow", totalStart); + printMetricsDelta("reduced paynote flow metrics", before, metrics.snapshot()); + } + + @Test + @Order(3) + void sameEventPathColdAndWarmTiming() { + BexProcessingMetrics.Snapshot beforeHotelCold = metrics.snapshot(); + long start = System.nanoTime(); + DocumentProcessingResult coldHotel = fixture.blue.processDocument(initializedDocument, hotelEvent); + double coldHotelMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterHotelCold = metrics.snapshot(); + assertFalse(coldHotel.capabilityFailure(), coldHotel.failureReason()); + + start = System.nanoTime(); + DocumentProcessingResult warmHotel = fixture.blue.processDocument(initializedDocument, hotelEvent); + double warmHotelMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterHotelWarm = metrics.snapshot(); + assertFalse(warmHotel.capabilityFailure(), warmHotel.failureReason()); + + BexProcessingMetrics.Snapshot beforeRestaurantCold = metrics.snapshot(); + start = System.nanoTime(); + DocumentProcessingResult coldRestaurant = fixture.blue.processDocument(initializedDocument, restaurantEvent); + double coldRestaurantMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterRestaurantCold = metrics.snapshot(); + assertFalse(coldRestaurant.capabilityFailure(), coldRestaurant.failureReason()); + + start = System.nanoTime(); + DocumentProcessingResult warmRestaurant = fixture.blue.processDocument(initializedDocument, restaurantEvent); + double warmRestaurantMs = elapsedMs(start); + BexProcessingMetrics.Snapshot afterRestaurantWarm = metrics.snapshot(); + assertFalse(warmRestaurant.capabilityFailure(), warmRestaurant.failureReason()); + + System.out.printf(Locale.ROOT, + "Paynote reduced BEX same-path cold/warm timing - coldHotelMs: %.3fms, warmHotelMs: %.3fms, " + + "coldRestaurantMs: %.3fms, warmRestaurantMs: %.3fms%n", + coldHotelMs, + warmHotelMs, + coldRestaurantMs, + warmRestaurantMs); + printMetricsDelta("same-path hotel cold delta", beforeHotelCold, afterHotelCold); + printMetricsDelta("same-path hotel warm delta", afterHotelCold, afterHotelWarm); + printMetricsDelta("same-path restaurant cold delta", beforeRestaurantCold, afterRestaurantCold); + printMetricsDelta("same-path restaurant warm delta", afterRestaurantCold, afterRestaurantWarm); + } + + @Test + @Order(4) + void eventProcessingOnlyTimingAfterWarmup() { + DocumentProcessingResult warmHotel = fixture.blue.processDocument(initializedDocument, hotelEvent); + assertFalse(warmHotel.capabilityFailure(), warmHotel.failureReason()); + DocumentProcessingResult warmRestaurant = fixture.blue.processDocument(warmHotel.document(), restaurantEvent); + assertFalse(warmRestaurant.capabilityFailure(), warmRestaurant.failureReason()); + + BexProcessingMetrics.Snapshot before = metrics.snapshot(); + long start = System.nanoTime(); + DocumentProcessingResult hotelResult = fixture.blue.processDocument(initializedDocument, hotelEvent); + double processHotelMs = elapsedMs(start); + + start = System.nanoTime(); + DocumentProcessingResult restaurantResult = fixture.blue.processDocument(hotelResult.document(), restaurantEvent); + double processRestaurantMs = elapsedMs(start); + BexProcessingMetrics.Snapshot after = metrics.snapshot(); + + assertFalse(hotelResult.capabilityFailure(), hotelResult.failureReason()); + assertFalse(restaurantResult.capabilityFailure(), restaurantResult.failureReason()); + assertEquals(Boolean.TRUE, restaurantResult.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + assertEquals(Boolean.TRUE, restaurantResult.document().get("/orders/package-order-a/restaurantOrder/resalePlaced")); + + System.out.printf(Locale.ROOT, + "Paynote reduced BEX event-only timing - processHotelMs: %.3fms, processRestaurantMs: %.3fms%n", + processHotelMs, + processRestaurantMs); + printMetricsDelta("event-only metrics delta", before, after); + assertEquals(2L, after.updateBatchPatchApplications - before.updateBatchPatchApplications); + assertEquals(0L, after.updateIndividualPatchApplications - before.updateIndividualPatchApplications); + } + + private static Node participantOperation(Fixture fixture, + String timelineId, + int timestamp, + String operation, + Node request) { + return CoordinationTestResources.operationRequestEvent(fixture.blue, + fixture.repository, + timelineId, + timestamp, + operation, + request); + } + + private static Node subscriptionUpdate(String subscriptionId, + String targetSessionId, + String requestId, + String orderSessionId) { + return new Node() + .type("Sample/Subscription Update") + .properties("subscriptionId", new Node().value(subscriptionId)) + .properties("targetSessionId", new Node().value(targetSessionId)) + .properties("update", new Node() + .properties("kind", new Node().value("Resale Order Placed")) + .properties("inResponseTo", new Node() + .properties("requestId", new Node().value(requestId))) + .properties("orderSessionId", new Node().value(orderSessionId))); + } + + private static Node loadYaml(Fixture fixture, String resourcePath) { + return CoordinationTestResources.yamlResource(fixture.blue, fixture.repository, resourcePath); + } + + private static void assertContainsType(List events, String expectedType) { + for (Node event : events) { + if (expectedType.equals(typeName(event))) { + return; + } + } + throw new AssertionError("Expected emitted event type " + expectedType + " in " + events); + } + + private static String typeName(Node event) { + if (event == null) { + return null; + } + if (event.getType() != null && event.getType().getValue() instanceof String) { + return (String) event.getType().getValue(); + } + Node type = event.getProperties() != null ? event.getProperties().get("type") : null; + Object value = type != null ? type.getValue() : null; + return value instanceof String ? (String) value : null; + } + + private static void printTiming(String label, long startNanos) { + System.out.printf(Locale.ROOT, "Paynote reduced BEX timing - %s: %.3fms%n", + label, + elapsedMs(startNanos)); + } + + private static double elapsedMs(long startNanos) { + return (System.nanoTime() - startNanos) / 1_000_000.0d; + } + + private static void printSetupTimings() { + System.out.printf(Locale.ROOT, "Paynote reduced BEX setup timing - setupBlueMs: %.3fms%n", setupBlueMs); + System.out.printf(Locale.ROOT, "Paynote reduced BEX setup timing - loadYamlMs: %.3fms%n", loadYamlMs); + System.out.printf(Locale.ROOT, "Paynote reduced BEX setup timing - initializeMs: %.3fms%n", initializeMs); + System.out.printf(Locale.ROOT, "Paynote reduced BEX setup timing - buildHotelEventMs: %.3fms%n", buildHotelEventMs); + System.out.printf(Locale.ROOT, "Paynote reduced BEX setup timing - buildRestaurantEventMs: %.3fms%n", buildRestaurantEventMs); + } + + private static void printMetrics(String label, BexProcessingMetrics.Snapshot snapshot) { + printMetrics(label, + snapshot.workflowStepsExecuted, + snapshot.computeStepsExecuted, + snapshot.updateDocumentStepsExecuted, + snapshot.triggerEventStepsExecuted, + snapshot.directBexChangesetHits, + snapshot.patchesApplied, + snapshot.updateBatchPatchApplications, + snapshot.updateIndividualPatchApplications, + snapshot.eventsEmitted, + snapshot.computeProgramNormalizations, + snapshot.computeDefinitionNormalizations); + printTimingMetrics(label, snapshot); + } + + private static void printMetrics(String label, + long workflowStepsExecuted, + long computeStepsExecuted, + long updateDocumentStepsExecuted, + long triggerEventStepsExecuted, + long directBexChangesetHits, + long patchesApplied, + long updateBatchPatchApplications, + long updateIndividualPatchApplications, + long eventsEmitted, + long computeProgramNormalizations, + long computeDefinitionNormalizations) { + System.out.printf(Locale.ROOT, + "Paynote reduced BEX %s - workflowSteps=%d, computeSteps=%d, updateSteps=%d, triggerSteps=%d, " + + "directChangesetHits=%d, patchesApplied=%d, " + + "batchPatchApplications=%d, individualPatchApplications=%d, eventsEmitted=%d, " + + "programNormalizations=%d, definitionNormalizations=%d%n", + label, + workflowStepsExecuted, + computeStepsExecuted, + updateDocumentStepsExecuted, + triggerEventStepsExecuted, + directBexChangesetHits, + patchesApplied, + updateBatchPatchApplications, + updateIndividualPatchApplications, + eventsEmitted, + computeProgramNormalizations, + computeDefinitionNormalizations); + } + + private static void printMetricsDelta(String label, + BexProcessingMetrics.Snapshot before, + BexProcessingMetrics.Snapshot after) { + printMetrics(label, + after.workflowStepsExecuted - before.workflowStepsExecuted, + after.computeStepsExecuted - before.computeStepsExecuted, + after.updateDocumentStepsExecuted - before.updateDocumentStepsExecuted, + after.triggerEventStepsExecuted - before.triggerEventStepsExecuted, + after.directBexChangesetHits - before.directBexChangesetHits, + after.patchesApplied - before.patchesApplied, + after.updateBatchPatchApplications - before.updateBatchPatchApplications, + after.updateIndividualPatchApplications - before.updateIndividualPatchApplications, + after.eventsEmitted - before.eventsEmitted, + after.computeProgramNormalizations - before.computeProgramNormalizations, + after.computeDefinitionNormalizations - before.computeDefinitionNormalizations); + printTimingMetricsDelta(label, before, after); + } + + private static void printTimingMetrics(String label, BexProcessingMetrics.Snapshot snapshot) { + System.out.printf(Locale.ROOT, + "Paynote reduced BEX %s timing metrics - workflowRunnerMs=%.3f, computeStepMs=%.3f, " + + "definitionResolveMs=%.3f, contextBuildMs=%.3f, programSourceBuildMs=%.3f, " + + "compileExecuteMs=%.3f, bexCompileMs=%.3f, bexExecuteMs=%.3f, " + + "updateStepMs=%.3f, directChangesetMs=%.3f, patchConversionMs=%.3f, " + + "patchApplyMs=%.3f, triggerStepMs=%.3f, emitEventMs=%.3f, " + + "bexNodeWriterMs=%.3f, compileCacheHits=%d, " + + "compileCacheMisses=%d, compiledExecutions=%d, definitionResolveHits=%d, " + + "definitionResolveMisses=%d, directPatchEntryConversions=%d%n", + label, + nanosToMs(snapshot.workflowRunnerNanos), + nanosToMs(snapshot.computeStepNanos), + nanosToMs(snapshot.computeDefinitionResolveNanos), + nanosToMs(snapshot.computeContextBuildNanos), + nanosToMs(snapshot.computeProgramSourceBuildNanos), + nanosToMs(snapshot.computeCompileExecuteNanos), + nanosToMs(snapshot.bexCompileNanos), + nanosToMs(snapshot.bexExecuteNanos), + nanosToMs(snapshot.updateStepNanos), + nanosToMs(snapshot.updateDirectChangesetNanos), + nanosToMs(snapshot.updatePatchConversionNanos), + nanosToMs(snapshot.updatePatchApplyNanos), + nanosToMs(snapshot.triggerStepNanos), + nanosToMs(snapshot.triggerEmitEventNanos), + nanosToMs(snapshot.bexNodeWriterNanos), + snapshot.bexCompileCacheHits, + snapshot.bexCompileCacheMisses, + snapshot.bexCompiledExecutions, + snapshot.computeDefinitionResolveHits, + snapshot.computeDefinitionResolveMisses, + snapshot.directBexPatchEntryConversions); + printOuterProcessingMetrics(label, + snapshot.blueProcessDocumentNanos, + snapshot.processDocumentNanos, + snapshot.eventPreprocessNanos, + snapshot.resultSnapshotAttachNanos, + snapshot.blueIdCalculationNanos, + snapshot.bundleLoadNanos, + snapshot.bundleLoadCacheKeyBuildNanos, + snapshot.bundleLoadActualBuildNanos, + snapshot.bundleLoadReuseNanos, + snapshot.bundleLoadCacheHits, + snapshot.bundleLoadCacheMisses, + snapshot.bundlesBuilt, + snapshot.bundlesReused, + snapshot.channelDiscoveryNanos, + snapshot.channelMatchNanos, + snapshot.channelEvaluations, + snapshot.handlerDiscoveryNanos, + snapshot.handlerMatchNanos, + snapshot.handlerMatchAttempts, + snapshot.handlerExecutionNanos, + snapshot.handlersExecuted, + snapshot.triggeredEventRoutingNanos, + snapshot.triggeredEventsRouted, + snapshot.checkpointUpdateNanos, + snapshot.snapshotCommitNanos, + snapshot.postProcessingNanos); + printPatchBatchMetrics(label, + snapshot.patchBoundaryNanos, + snapshot.patchGasNanos, + snapshot.documentUpdateRoutingNanos, + snapshot.documentUpdateEventsBuilt, + snapshot.documentUpdateEventsSkippedNoChannel, + snapshot.batchPatchPlanningNanos, + snapshot.batchPatchConformanceNanos, + snapshot.batchPatchBuildUpdatesNanos, + snapshot.batchPatchCommitNanos, + snapshot.documentUpdateBeforeMaterializations, + snapshot.documentUpdateAfterMaterializations); + } + + private static void printTimingMetricsDelta(String label, + BexProcessingMetrics.Snapshot before, + BexProcessingMetrics.Snapshot after) { + System.out.printf(Locale.ROOT, + "Paynote reduced BEX %s timing metrics - workflowRunnerMs=%.3f, computeStepMs=%.3f, " + + "definitionResolveMs=%.3f, contextBuildMs=%.3f, programSourceBuildMs=%.3f, " + + "compileExecuteMs=%.3f, bexCompileMs=%.3f, bexExecuteMs=%.3f, " + + "updateStepMs=%.3f, directChangesetMs=%.3f, patchConversionMs=%.3f, " + + "patchApplyMs=%.3f, triggerStepMs=%.3f, emitEventMs=%.3f, " + + "bexNodeWriterMs=%.3f, compileCacheHits=%d, " + + "compileCacheMisses=%d, compiledExecutions=%d, definitionResolveHits=%d, " + + "definitionResolveMisses=%d, directPatchEntryConversions=%d%n", + label, + nanosToMs(after.workflowRunnerNanos - before.workflowRunnerNanos), + nanosToMs(after.computeStepNanos - before.computeStepNanos), + nanosToMs(after.computeDefinitionResolveNanos - before.computeDefinitionResolveNanos), + nanosToMs(after.computeContextBuildNanos - before.computeContextBuildNanos), + nanosToMs(after.computeProgramSourceBuildNanos - before.computeProgramSourceBuildNanos), + nanosToMs(after.computeCompileExecuteNanos - before.computeCompileExecuteNanos), + nanosToMs(after.bexCompileNanos - before.bexCompileNanos), + nanosToMs(after.bexExecuteNanos - before.bexExecuteNanos), + nanosToMs(after.updateStepNanos - before.updateStepNanos), + nanosToMs(after.updateDirectChangesetNanos - before.updateDirectChangesetNanos), + nanosToMs(after.updatePatchConversionNanos - before.updatePatchConversionNanos), + nanosToMs(after.updatePatchApplyNanos - before.updatePatchApplyNanos), + nanosToMs(after.triggerStepNanos - before.triggerStepNanos), + nanosToMs(after.triggerEmitEventNanos - before.triggerEmitEventNanos), + nanosToMs(after.bexNodeWriterNanos - before.bexNodeWriterNanos), + after.bexCompileCacheHits - before.bexCompileCacheHits, + after.bexCompileCacheMisses - before.bexCompileCacheMisses, + after.bexCompiledExecutions - before.bexCompiledExecutions, + after.computeDefinitionResolveHits - before.computeDefinitionResolveHits, + after.computeDefinitionResolveMisses - before.computeDefinitionResolveMisses, + after.directBexPatchEntryConversions - before.directBexPatchEntryConversions); + printOuterProcessingMetrics(label, + after.blueProcessDocumentNanos - before.blueProcessDocumentNanos, + after.processDocumentNanos - before.processDocumentNanos, + after.eventPreprocessNanos - before.eventPreprocessNanos, + after.resultSnapshotAttachNanos - before.resultSnapshotAttachNanos, + after.blueIdCalculationNanos - before.blueIdCalculationNanos, + after.bundleLoadNanos - before.bundleLoadNanos, + after.bundleLoadCacheKeyBuildNanos - before.bundleLoadCacheKeyBuildNanos, + after.bundleLoadActualBuildNanos - before.bundleLoadActualBuildNanos, + after.bundleLoadReuseNanos - before.bundleLoadReuseNanos, + after.bundleLoadCacheHits - before.bundleLoadCacheHits, + after.bundleLoadCacheMisses - before.bundleLoadCacheMisses, + after.bundlesBuilt - before.bundlesBuilt, + after.bundlesReused - before.bundlesReused, + after.channelDiscoveryNanos - before.channelDiscoveryNanos, + after.channelMatchNanos - before.channelMatchNanos, + after.channelEvaluations - before.channelEvaluations, + after.handlerDiscoveryNanos - before.handlerDiscoveryNanos, + after.handlerMatchNanos - before.handlerMatchNanos, + after.handlerMatchAttempts - before.handlerMatchAttempts, + after.handlerExecutionNanos - before.handlerExecutionNanos, + after.handlersExecuted - before.handlersExecuted, + after.triggeredEventRoutingNanos - before.triggeredEventRoutingNanos, + after.triggeredEventsRouted - before.triggeredEventsRouted, + after.checkpointUpdateNanos - before.checkpointUpdateNanos, + after.snapshotCommitNanos - before.snapshotCommitNanos, + after.postProcessingNanos - before.postProcessingNanos); + printPatchBatchMetrics(label, + after.patchBoundaryNanos - before.patchBoundaryNanos, + after.patchGasNanos - before.patchGasNanos, + after.documentUpdateRoutingNanos - before.documentUpdateRoutingNanos, + after.documentUpdateEventsBuilt - before.documentUpdateEventsBuilt, + after.documentUpdateEventsSkippedNoChannel - before.documentUpdateEventsSkippedNoChannel, + after.batchPatchPlanningNanos - before.batchPatchPlanningNanos, + after.batchPatchConformanceNanos - before.batchPatchConformanceNanos, + after.batchPatchBuildUpdatesNanos - before.batchPatchBuildUpdatesNanos, + after.batchPatchCommitNanos - before.batchPatchCommitNanos, + after.documentUpdateBeforeMaterializations - before.documentUpdateBeforeMaterializations, + after.documentUpdateAfterMaterializations - before.documentUpdateAfterMaterializations); + } + + private static void printOuterProcessingMetrics(String label, + long blueProcessDocumentNanos, + long processDocumentNanos, + long eventPreprocessNanos, + long resultSnapshotAttachNanos, + long blueIdCalculationNanos, + long bundleLoadNanos, + long bundleLoadCacheKeyBuildNanos, + long bundleLoadActualBuildNanos, + long bundleLoadReuseNanos, + long bundleLoadCacheHits, + long bundleLoadCacheMisses, + long bundlesBuilt, + long bundlesReused, + long channelDiscoveryNanos, + long channelMatchNanos, + long channelEvaluations, + long handlerDiscoveryNanos, + long handlerMatchNanos, + long handlerMatchAttempts, + long handlerExecutionNanos, + long handlersExecuted, + long triggeredEventRoutingNanos, + long triggeredEventsRouted, + long checkpointUpdateNanos, + long snapshotCommitNanos, + long postProcessingNanos) { + long attributed = eventPreprocessNanos + + bundleLoadNanos + + channelDiscoveryNanos + + channelMatchNanos + + handlerDiscoveryNanos + + handlerMatchNanos + + handlerExecutionNanos + + checkpointUpdateNanos + + postProcessingNanos; + long processorUnattributed = Math.max(0L, processDocumentNanos - attributed); + long blueUnattributed = Math.max(0L, + blueProcessDocumentNanos - processDocumentNanos - resultSnapshotAttachNanos); + System.out.printf(Locale.ROOT, + "Paynote reduced BEX %s outer processing metrics - blueProcessDocumentMs=%.3f, " + + "processorProcessDocumentMs=%.3f, resultSnapshotAttachMs=%.3f, " + + "blueIdCalculationMs=%.3f, eventPreprocessMs=%.3f, bundleLoadMs=%.3f, " + + "bundleKeyBuildMs=%.3f, bundleActualBuildMs=%.3f, bundleReuseMs=%.3f, " + + "bundleCacheHits=%d, bundleCacheMisses=%d, bundlesBuilt=%d, bundlesReused=%d, " + + "channelDiscoveryMs=%.3f, " + + "channelMatchMs=%.3f, channelEvaluations=%d, handlerDiscoveryMs=%.3f, " + + "handlerMatchMs=%.3f, handlerMatchAttempts=%d, handlerExecutionMs=%.3f, " + + "handlersExecuted=%d, triggeredEventRoutingMs=%.3f, triggeredEventsRouted=%d, " + + "checkpointUpdateMs=%.3f, snapshotCommitMs=%.3f, postProcessingMs=%.3f, " + + "processorUnattributedMs=%.3f, blueUnattributedMs=%.3f%n", + label, + nanosToMs(blueProcessDocumentNanos), + nanosToMs(processDocumentNanos), + nanosToMs(resultSnapshotAttachNanos), + nanosToMs(blueIdCalculationNanos), + nanosToMs(eventPreprocessNanos), + nanosToMs(bundleLoadNanos), + nanosToMs(bundleLoadCacheKeyBuildNanos), + nanosToMs(bundleLoadActualBuildNanos), + nanosToMs(bundleLoadReuseNanos), + bundleLoadCacheHits, + bundleLoadCacheMisses, + bundlesBuilt, + bundlesReused, + nanosToMs(channelDiscoveryNanos), + nanosToMs(channelMatchNanos), + channelEvaluations, + nanosToMs(handlerDiscoveryNanos), + nanosToMs(handlerMatchNanos), + handlerMatchAttempts, + nanosToMs(handlerExecutionNanos), + handlersExecuted, + nanosToMs(triggeredEventRoutingNanos), + triggeredEventsRouted, + nanosToMs(checkpointUpdateNanos), + nanosToMs(snapshotCommitNanos), + nanosToMs(postProcessingNanos), + nanosToMs(processorUnattributed), + nanosToMs(blueUnattributed)); + } + + private static void printPatchBatchMetrics(String label, + long patchBoundaryNanos, + long patchGasNanos, + long documentUpdateRoutingNanos, + long documentUpdateEventsBuilt, + long documentUpdateEventsSkippedNoChannel, + long batchPatchPlanningNanos, + long batchPatchConformanceNanos, + long batchPatchBuildUpdatesNanos, + long batchPatchCommitNanos, + long documentUpdateBeforeMaterializations, + long documentUpdateAfterMaterializations) { + System.out.printf(Locale.ROOT, + "Paynote reduced BEX %s batch patch metrics - patchBoundaryMs=%.3f, patchGasMs=%.3f, " + + "documentUpdateRoutingMs=%.3f, documentUpdateEventsBuilt=%d, " + + "documentUpdateEventsSkippedNoChannel=%d, batchPlanningMs=%.3f, " + + "batchConformanceMs=%.3f, batchBuildUpdatesMs=%.3f, batchCommitMs=%.3f, " + + "documentUpdateBeforeMaterializations=%d, documentUpdateAfterMaterializations=%d%n", + label, + nanosToMs(patchBoundaryNanos), + nanosToMs(patchGasNanos), + nanosToMs(documentUpdateRoutingNanos), + documentUpdateEventsBuilt, + documentUpdateEventsSkippedNoChannel, + nanosToMs(batchPatchPlanningNanos), + nanosToMs(batchPatchConformanceNanos), + nanosToMs(batchPatchBuildUpdatesNanos), + nanosToMs(batchPatchCommitNanos), + documentUpdateBeforeMaterializations, + documentUpdateAfterMaterializations); + } + + private static double nanosToMs(long nanos) { + return nanos / 1_000_000.0d; + } + + private static Fixture configuredFixture(BexProcessingMetrics metrics) { + BlueRepository repository = BlueRepository.v1_3_0(); + Blue blue = CoordinationTestResources.configuredBlue(repository); + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + TestTimelineProvider.registerWith(blue); + return new Fixture(repository, blue); + } + + private static final class Fixture { + private final BlueRepository repository; + private final Blue blue; + + private Fixture(BlueRepository repository, Blue blue) { + this.repository = repository; + this.blue = blue; + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java b/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java new file mode 100644 index 0000000..c5dc19c --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java @@ -0,0 +1,179 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import java.math.BigInteger; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Scenario: + * BEX-produced changesets flow through the language batch patch API. + * + * Main flow: + * 1. Compute builds patch data, including duplicate paths where order matters. + * 2. Compute applies returned changesets directly through batch apply. + * 3. Additional cases prove later Compute steps see patched state, and + * literal Update Document changesets still use batch apply. + * + * Actors and operations: + * - The owner timeline calls {@code run}. + * - Compute creates changesets and events. + * - Update Document remains supported for literal or separately authored patches. + */ +class UpdateDocumentBatchApplyIntegrationTest { + @Test + void computeChangesetUsesLanguageBatchApplyAndPreservesPatchOrder() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + Node document = support.initialize(support.yaml(support.operationWorkflowDocumentWithStatus("count: 0", + String.join("\n", + " steps:", + " - name: BuildPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val: first", + " - $appendChange:", + " op: replace", + " path: /count", + " val: 1", + " - $appendChange:", + " op: replace", + " path: /status", + " val: second", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true")))).document(); + + DocumentProcessingResult result = support.processRun(document); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertEquals("second", result.document().getAsText("/status")); + assertEquals(BigInteger.ONE, result.document().get("/count")); + assertEquals(3L, metrics.patchesApplied()); + assertEquals(1L, metrics.updateBatchPatchApplications()); + assertEquals(0L, metrics.updateIndividualPatchApplications()); + assertEquals(1L, metrics.directBexChangesetHits()); + } + + @Test + void pureBexComputeEventUsesBatchApply() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: BuildPatch", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val:", + " $binding:", + " name: event", + " path: /message/request/status", + " - $return:", + " changeset:", + " $changeset: true", + " events:", + " $events: true", + " - name: BuildEvent", + " type: Coordination/Compute", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind: Status Applied", + " status:", + " $document: /status", + " - $return:", + " events:", + " $events: true")); + + DocumentProcessingResult result = support.processRun(document, + new Node().properties("status", new Node().value("active"))); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertEquals("active", result.document().get("/status")); + assertEquals(1, result.triggeredEvents().size()); + assertEquals("Status Applied", result.triggeredEvents().get(0).get("/kind")); + assertEquals("active", result.triggeredEvents().get(0).get("/status")); + assertEquals(1L, metrics.patchesApplied()); + assertEquals(1L, metrics.updateBatchPatchApplications()); + assertEquals(0L, metrics.updateIndividualPatchApplications()); + assertEquals(1L, metrics.directBexChangesetHits()); + assertEquals(1L, metrics.eventsEmitted()); + } + + @Test + void literalUpdateDocumentChangesetsUseBatchApply() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: ApplyLiteral", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val: literal", + " - name: ApplySecondLiteral", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val: existing")); + + DocumentProcessingResult result = support.processRun(document, + new Node() + .properties("detail", new Node().value("detail")) + .properties("status", new Node().value("existing"))); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertEquals("existing", result.document().get("/status")); + assertEquals(2L, metrics.patchesApplied()); + assertEquals(2L, metrics.updateBatchPatchApplications()); + assertEquals(0L, metrics.updateIndividualPatchApplications()); + } + + @Test + void updateDocumentRejectsBexOperatorsInStaticChangeset() { + ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create(); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: ApplyPatch", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val:", + " $binding:", + " name: event", + " path: /message/request/status")); + + DocumentProcessingResult result = support.processRun(document, + new Node().properties("status", new Node().value("existing"))); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason().contains("Update Document changeset must be static")); + } +} diff --git a/src/test/resources/coordination/compute/bex-counter-persistence.yaml b/src/test/resources/coordination/compute/bex-counter-persistence.yaml new file mode 100644 index 0000000..a37de23 --- /dev/null +++ b/src/test/resources/coordination/compute/bex-counter-persistence.yaml @@ -0,0 +1,30 @@ +name: Persistent BEX Counter +counter: 0 +contracts: + ownerChannel: + type: Coordination/Timeline Channel + timelineId: owner + increment: + type: Coordination/Operation + channel: ownerChannel + incrementImpl: + type: Coordination/Sequential Workflow Operation + operation: increment + steps: + - name: BuildPatch + type: Coordination/Compute + do: + - $appendChange: + op: replace + path: /counter + val: + $add: + - $document: /counter + - $binding: + name: event + path: /message/request + - $return: + changeset: + $changeset: true + events: + $events: true diff --git a/src/test/resources/coordination/compute/dynamic-embedded-participants-bex.yaml b/src/test/resources/coordination/compute/dynamic-embedded-participants-bex.yaml new file mode 100644 index 0000000..19a1d00 --- /dev/null +++ b/src/test/resources/coordination/compute/dynamic-embedded-participants-bex.yaml @@ -0,0 +1,274 @@ +name: Dynamic Embedded Participants +# Root counters. Alice creates embedded participant documents, embedded participants emit chat +# messages, and Bob checks whether the root document has seen enough messages. +nextEmbeddedNumber: 0 +chatMessagesSeen: 0 +embeddedTimelineEventsSeen: 0 +success: false +# Generic embedded document template. createEmbedded copies this object and specializes the display +# name, timeline id, and chat message for each newly created /embedded_N document. +embeddedTemplate: + name: Embedded + displayName: Embedded + contracts: + participantChannel: + type: Coordination/Timeline Channel + timelineId: embedded + say: + type: Coordination/Operation + channel: participantChannel + sayImpl: + type: Coordination/Sequential Workflow Operation + operation: say + steps: + - type: Coordination/Trigger Event + event: + type: Coordination/Chat Message + message: Chat from embedded +contractTemplates: + # Template for the root-level timeline channel that points at one generated embedded document. + embeddedTimeline: + type: Coordination/Timeline Channel + timelineId: embedded + # Template for the root-level embedded node channel that surfaces events from one generated child. + embeddedBridge: + type: Embedded Node Channel + childPath: /embedded + # Template for the root-level workflow that counts chat messages from one generated child. + embeddedChatCounter: + type: Coordination/Sequential Workflow + channel: embedded_bridge + event: + type: Coordination/Chat Message + steps: + - name: BuildChatCounterPatch + type: Coordination/Compute + do: + - $appendChange: + op: replace + path: /chatMessagesSeen + val: + $add: + - $document: /chatMessagesSeen + - 1 + - $return: + changeset: + $changeset: true + events: + $events: true +contracts: + # Alice is allowed to create embedded participant documents. + aliceChannel: + type: Coordination/Timeline Channel + timelineId: alice + # Bob is allowed to check whether enough embedded chat messages have been observed. + bobChannel: + type: Coordination/Timeline Channel + timelineId: bob + createEmbedded: + type: Coordination/Operation + channel: aliceChannel + createEmbeddedImpl: + type: Coordination/Sequential Workflow Operation + operation: createEmbedded + steps: + - name: BuildEmbedded + type: Coordination/Compute + do: + # createEmbedded creates exactly one new document: + # - /embedded_N is copied from /embeddedTemplate; + # - its participant channel is changed to embedded-N; + # - its say operation emits a unique message; + # - root contracts are added so the main document can route to and observe it. + - $let: + name: index + expr: + $add: + - $document: /nextEmbeddedNumber + - 1 + - $let: + name: suffix + expr: + $text: + $var: index + - $appendChange: + op: replace + path: /nextEmbeddedNumber + val: + $var: index + - $appendChange: + op: add + path: + $concat: + - /embedded_ + - $var: suffix + val: + $document: /embeddedTemplate + - $appendChange: + op: replace + path: + $concat: + - /embedded_ + - $var: suffix + - /displayName + val: + $concat: + - Embedded + - " " + - $var: suffix + - $appendChange: + op: replace + path: + $concat: + - /embedded_ + - $var: suffix + - /contracts/participantChannel/timelineId + val: + $concat: + - embedded- + - $var: suffix + - $appendChange: + op: replace + path: + $concat: + - /embedded_ + - $var: suffix + - /contracts/sayImpl/steps/0/event/message + val: + $concat: + - Chat from embedded + - " " + - $var: suffix + - $appendChange: + op: add + path: /contracts/embeddedDocs/paths/- + val: + $concat: + - /embedded_ + - $var: suffix + - $appendChange: + op: add + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _timeline + val: + $document: /contractTemplates/embeddedTimeline + - $appendChange: + op: replace + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _timeline/timelineId + val: + $concat: + - embedded- + - $var: suffix + - $appendChange: + op: add + path: /contracts/allEmbeddedTimelines/channels/- + val: + $concat: + - embedded_ + - $var: suffix + - _timeline + - $appendChange: + op: add + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _bridge + val: + $document: /contractTemplates/embeddedBridge + - $appendChange: + op: replace + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _bridge/childPath + val: + $concat: + - /embedded_ + - $var: suffix + - $appendChange: + op: add + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _chatCounter + val: + $document: /contractTemplates/embeddedChatCounter + - $appendChange: + op: replace + path: + $concat: + - /contracts/embedded_ + - $var: suffix + - _chatCounter/channel + val: + $concat: + - embedded_ + - $var: suffix + - _bridge + - $return: + changeset: + $changeset: true + events: + $events: true + embeddedDocs: + type: Process Embedded + paths: [] + # Composite channel over all generated embedded timelines. It starts empty and createEmbedded appends + # one generated timeline contract key per created participant. + allEmbeddedTimelines: + type: Coordination/Composite Timeline Channel + channels: [] + embeddedTimelineObserver: + type: Coordination/Sequential Workflow + channel: allEmbeddedTimelines + steps: + - name: BuildEmbeddedTimelineEventPatch + type: Coordination/Compute + do: + # Any operation on a generated embedded timeline increments this root-level observer counter. + - $appendChange: + op: replace + path: /embeddedTimelineEventsSeen + val: + $add: + - $document: /embeddedTimelineEventsSeen + - 1 + - $return: + changeset: + $changeset: true + events: + $events: true + checkChatCount: + type: Coordination/Operation + channel: bobChannel + checkChatCountImpl: + type: Coordination/Sequential Workflow Operation + operation: checkChatCount + steps: + - name: BuildCheck + type: Coordination/Compute + do: + # Bob's check is deliberately simple: success becomes true once five embedded chat messages + # have been bridged back to the main document. + - $appendChange: + op: replace + path: /success + val: + $gte: + - $document: /chatMessagesSeen + - 5 + - $return: + changeset: + $changeset: true + events: + $events: true diff --git a/src/test/resources/coordination/compute/ed25519-hotel-access.yaml b/src/test/resources/coordination/compute/ed25519-hotel-access.yaml new file mode 100644 index 0000000..6b3a9d7 --- /dev/null +++ b/src/test/resources/coordination/compute/ed25519-hotel-access.yaml @@ -0,0 +1,180 @@ +name: Ed25519 Hotel Access +guestPublicKeys: + customerA: uo2WYCfAYaiRPfdHd0L3H0Um10zlss7-w1ALJo46nAQ +usedNonces: {} +contracts: + hotelChannel: + type: Coordination/Timeline Channel + timelineId: hotel + checkIn: + type: Coordination/Operation + channel: hotelChannel + checkInImpl: + type: Coordination/Sequential Workflow Operation + operation: checkIn + steps: + - name: VerifyCheckIn + type: Coordination/Compute + entry: handleCheckIn + functions: + handleCheckIn: + do: + - $let: + order: + - req + - userId + - nonce + - publicKey + - noncesBefore + - valid + - noncesAfter + vars: + req: + $event: /message/request + userId: + $var: + name: req + path: /userId + nonce: + $var: + name: req + path: /nonce + publicKey: + $document: + path: + $pointerJoin: + - guestPublicKeys + - $var: userId + noncesBefore: + $object: + $document: + path: + $pointerJoin: + - usedNonces + - $var: userId + valid: + $and: + - $exists: + $var: publicKey + - $not: + $hasKey: + object: + $var: noncesBefore + key: + $var: nonce + - $gte: + - $var: + name: req + path: /expires + - $event: /timestamp + - $call: + function: ed25519SignatureValid + args: + publicKey: + $var: publicKey + message: + $call: + function: checkInMessage + args: + reservationId: + $var: + name: req + path: /reservationId + userId: + $var: userId + nonce: + $var: nonce + expires: + $var: + name: req + path: /expires + signature: + $var: + name: req + path: /signature + noncesAfter: + $objectSet: + object: + $var: noncesBefore + key: + $var: nonce + val: true + - $if: + cond: + $var: valid + then: + - $appendChange: + op: add + path: + $pointerJoin: + - usedNonces + - $var: userId + val: + $var: noncesAfter + - $appendEvent: + type: Coordination/Event + kind: Hotel Access Granted + userId: + $var: userId + reservationId: + $var: + name: req + path: /reservationId + else: + - $appendEvent: + type: Coordination/Event + kind: Hotel Access Rejected + userId: + $var: userId + reason: request not authorized + - $return: + changeset: + $changeset: true + events: + $events: true + checkInMessage: + args: + reservationId: + type: Text + userId: + type: Text + nonce: + type: Text + expires: + type: Integer + expr: + $join: + separator: "\n" + list: + - action=hotel.checkIn + - $concat: + - reservation= + - $var: reservationId + - $concat: + - user= + - $var: userId + - $concat: + - nonce= + - $var: nonce + - $concat: + - expires= + - $text: + $var: expires + ed25519SignatureValid: + args: + publicKey: + type: Text + message: + type: Text + signature: + type: Text + expr: + $intrinsic: + type: + blueId: Common/Crypto Ed25519 Verify + publicKey: + $var: publicKey + message: + $var: message + signature: + $var: signature diff --git a/src/test/resources/coordination/compute/ed25519-threshold-approval.yaml b/src/test/resources/coordination/compute/ed25519-threshold-approval.yaml new file mode 100644 index 0000000..ec31340 --- /dev/null +++ b/src/test/resources/coordination/compute/ed25519-threshold-approval.yaml @@ -0,0 +1,281 @@ +name: Ed25519 Threshold Approval +threshold: 2 +approvers: + alice: + publicKey: LOgXd50ECwNhvHNfK3cK3VpwioLBUR_UTqn5pL9buuA + bob: + publicKey: qEeYRYybYAbUnAFl679BmtRDfjMGBmDSMkTl0C4VaZY + celine: + publicKey: B2nXg4XITq3MNfp3by1H0DouYSAp5p4nN0Q0zhUkwic +usedNonces: + alice: {} + bob: {} + celine: {} +approvals: + delete-file-123: {} +executed: {} +contracts: + adminChannel: + type: Coordination/Timeline Channel + timelineId: admin + approveAction: + type: Coordination/Operation + channel: adminChannel + approveActionImpl: + type: Coordination/Sequential Workflow Operation + operation: approveAction + steps: + - name: VerifyApproval + type: Coordination/Compute + entry: handleApproval + functions: + handleApproval: + do: + - $let: + order: + - req + - actionId + - signer + - nonce + - publicKey + - noncesBefore + - approvalsBefore + - approvalsAfter + - approvalCount + - validSignature + - alreadyExecuted + - approved + vars: + req: + $event: /message/request + actionId: + $var: + name: req + path: /actionId + signer: + $var: + name: req + path: /signer + nonce: + $var: + name: req + path: /nonce + publicKey: + $document: + path: + $pointerJoin: + - approvers + - $var: signer + - publicKey + noncesBefore: + $object: + $document: + path: + $pointerJoin: + - usedNonces + - $var: signer + approvalsBefore: + $object: + $document: + path: + $pointerJoin: + - approvals + - $var: actionId + approvalsAfter: + $objectSet: + object: + $var: approvalsBefore + key: + $var: signer + val: true + approvalCount: + $size: + $keys: + $var: approvalsAfter + validSignature: + $and: + - $exists: + $var: publicKey + - $not: + $hasKey: + object: + $var: noncesBefore + key: + $var: nonce + - $gte: + - $var: + name: req + path: /expires + - $event: /timestamp + - $call: + function: ed25519SignatureValid + args: + publicKey: + $var: publicKey + message: + $call: + function: approvalMessage + args: + action: + $var: + name: req + path: /action + actionId: + $var: actionId + resource: + $var: + name: req + path: /resource + signer: + $var: signer + nonce: + $var: nonce + expires: + $var: + name: req + path: /expires + signature: + $var: + name: req + path: /signature + alreadyExecuted: + $hasKey: + object: + $object: + $document: /executed + key: + $var: actionId + approved: + $gte: + - $var: approvalCount + - $document: /threshold + - $if: + cond: + $var: validSignature + then: + - $appendChange: + op: add + path: + $pointerJoin: + - usedNonces + - $var: signer + - $var: nonce + val: + true + - $appendChange: + op: add + path: + $pointerJoin: + - approvals + - $var: actionId + - $var: signer + val: + true + - $if: + cond: + $and: + - $var: approved + - $not: + $var: alreadyExecuted + then: + - $appendChange: + op: add + path: + $pointerJoin: + - executed + - $var: actionId + val: true + - $appendEvent: + type: Coordination/Event + kind: Admin Action Executed + actionId: + $var: actionId + action: + $var: + name: req + path: /action + resource: + $var: + name: req + path: /resource + approvalCount: + $var: approvalCount + else: + - $appendEvent: + type: Coordination/Event + kind: Admin Approval Recorded + actionId: + $var: actionId + signer: + $var: signer + approvalCount: + $var: approvalCount + else: + - $appendEvent: + type: Coordination/Event + kind: Admin Approval Rejected + actionId: + $var: actionId + signer: + $var: signer + reason: request not authorized + - $return: + changeset: + $changeset: true + events: + $events: true + approvalMessage: + args: + action: + type: Text + actionId: + type: Text + resource: + type: Text + signer: + type: Text + nonce: + type: Text + expires: + type: Integer + expr: + $join: + separator: "\n" + list: + - $concat: + - action= + - $var: action + - $concat: + - actionId= + - $var: actionId + - $concat: + - resource= + - $var: resource + - $concat: + - signer= + - $var: signer + - $concat: + - nonce= + - $var: nonce + - $concat: + - expires= + - $text: + $var: expires + ed25519SignatureValid: + args: + publicKey: + type: Text + message: + type: Text + signature: + type: Text + expr: + $intrinsic: + type: + blueId: Common/Crypto Ed25519 Verify + publicKey: + $var: publicKey + message: + $var: message + signature: + $var: signature diff --git a/src/test/resources/coordination/compute/offer-paynote-embedded-orders-bex.yaml b/src/test/resources/coordination/compute/offer-paynote-embedded-orders-bex.yaml new file mode 100644 index 0000000..bf8b7bf --- /dev/null +++ b/src/test/resources/coordination/compute/offer-paynote-embedded-orders-bex.yaml @@ -0,0 +1,176 @@ +name: Weekend Package Order +package: + id: weekend-badura-cud-malina + title: 20-21 June weekend + description: Deluxe Room in Hotel Badura plus 250zl Dinner for Two at Restaurant Cud Malina + hotelName: Hotel Badura + roomType: Deluxe Room + restaurantName: Restaurant Cud Malina + dinnerDescription: 250zl Dinner for Two + startDate: 2026-06-20 + endDate: 2026-06-21 + price: + amount: 499 + currency: PLN +order: + status: Awaiting PayNote + customer: Customer + travelAgency: Travel Agency + paynoteDelivered: false +contracts: + # Customer and Travel Agency are the package-order participants. + customerChannel: + type: Coordination/Timeline Channel + timelineId: customer + travelAgencyChannel: + type: Coordination/Timeline Channel + timelineId: travel-agency + packageParticipants: + type: Coordination/Composite Timeline Channel + channels: + - customerChannel + - travelAgencyChannel + + # Deliver PayNote embeds the exact PayNote document supplied in the operation request. + # Fixed document shape belongs on the Operation request pattern. Stateful checks, such as + # "the order is still waiting for a PayNote", stay in the workflow. + # Illegal examples: + # - A request with amount 498 PLN does not match this operation. + # - A request for another package id does not match this operation. + # - A second PayNote reaches the workflow and fails because /paynote is already present. + deliverPaynote: + type: Coordination/Operation + channel: packageParticipants + request: + name: Package PayNote + packageId: weekend-badura-cud-malina + status: Pending authorization + amount: 499 + currency: PLN + startDate: 2026-06-20 + endDate: 2026-06-21 + customer: Customer + travelAgency: Travel Agency + cardProcessor: Card Processor + restaurantOrderProvided: false + hotelOrderProvided: false + restaurantConfirmed: false + hotelConfirmed: false + captureRequested: false + captured: false + contracts: + travelAgencyChannel: + timelineId: travel-agency + cardProcessorChannel: + timelineId: card-processor + confirmAuthorization: + channel: cardProcessorChannel + confirmAuthorizationImpl: + operation: confirmAuthorization + provideRestaurantOrder: + channel: travelAgencyChannel + provideRestaurantOrderImpl: + operation: provideRestaurantOrder + provideHotelOrder: + channel: travelAgencyChannel + provideHotelOrderImpl: + operation: provideHotelOrder + restaurantOrderEvents: + childPath: /restaurantOrder + hotelOrderEvents: + childPath: /hotelOrder + restaurantOrderConfirmed: + channel: restaurantOrderEvents + hotelOrderConfirmed: + channel: hotelOrderEvents + confirmCapture: + channel: cardProcessorChannel + confirmCaptureImpl: + operation: confirmCapture + deliverPaynoteImpl: + type: Coordination/Sequential Workflow Operation + operation: deliverPaynote + steps: + - name: BuildDeliverPaynotePatch + type: Coordination/Compute + do: + - $if: + cond: + $ne: + - $document: /order/status + - Awaiting PayNote + then: + - $fail: Package order is not waiting for a PayNote + - $if: + cond: + $not: + $empty: + $document: /paynote + then: + - $fail: PayNote is already delivered + - $appendChange: + op: add + path: /paynote + val: + $binding: + name: event + path: /message/request + - $appendChange: + op: add + path: /contracts/embeddedPaynotes/paths/- + val: /paynote + - $appendChange: + op: replace + path: /order/paynoteDelivered + val: true + - $appendChange: + op: replace + path: /order/status + val: Waiting for PayNote capture + - $appendEvent: + type: Coordination/Event + kind: PayNote Authorization Requested + packageId: + $document: /package/id + amount: 499 + currency: PLN + - $return: + changeset: + $changeset: true + events: + $events: true + + # The embedded PayNote and its nested component orders are processed through this embedded scope. + embeddedPaynotes: + type: Process Embedded + paths: [] + + # The package order becomes Ready to use only when the embedded PayNote writes captured=true. + paynoteCapturedUpdate: + type: Document Update Channel + path: /paynote/captured + paynoteCaptured: + type: Coordination/Sequential Workflow + channel: paynoteCapturedUpdate + event: + type: Document Update + path: /paynote/captured + after: true + steps: + - name: BuildReadyToUsePatch + type: Coordination/Compute + do: + - $appendChange: + op: replace + path: /order/status + val: Ready to use + - $appendEvent: + type: Coordination/Event + kind: Package Order Ready to Use + packageId: + $document: /package/id + - $return: + changeset: + $changeset: true + events: + $events: true diff --git a/src/test/resources/coordination/counter-bex.yaml b/src/test/resources/coordination/counter-bex.yaml new file mode 100644 index 0000000..88a6506 --- /dev/null +++ b/src/test/resources/coordination/counter-bex.yaml @@ -0,0 +1,51 @@ +name: Counter +counter: 0 +contracts: + ownerChannel: + type: Coordination/Timeline Channel + timelineId: counter-timeline + increment: + description: Increment the counter by the given number + type: Coordination/Operation + channel: ownerChannel + request: + description: Represents a value by which counter will be incremented + type: Integer + incrementImpl: + type: Coordination/Sequential Workflow Operation + operation: increment + steps: + - name: IncrementAndEmit + type: Coordination/Compute + do: + - $let: + name: nextCounter + expr: + $add: + - $document: /counter + - $binding: + name: event + path: /message/request + - $appendChange: + op: replace + path: /counter + val: + $var: nextCounter + - $appendEvent: + $merge: + - type: Coordination/Chat Message + - message: + $concat: + - Counter was incremented by + - " " + - $binding: + name: event + path: /message/request + - " and is now " + - $text: + $var: nextCounter + - $return: + changeset: + $changeset: true + events: + $events: true diff --git a/src/test/resources/processor-delay/customer-paynote-snapshot.document.compute.latest-bex.yaml b/src/test/resources/processor-delay/customer-paynote-snapshot.document.compute.latest-bex.yaml new file mode 100644 index 0000000..541a10f --- /dev/null +++ b/src/test/resources/processor-delay/customer-paynote-snapshot.document.compute.latest-bex.yaml @@ -0,0 +1,10435 @@ +{ + "name": "Global Package Fulfillment Automation - Weekend Stay + Wine Dinner", + "description": "Investor-side setup automation that watches package offer and agreement anchors and coordinates concurrent public checkouts.", + "type": "Sample/Sample Admin Base", + "contracts": { + "sampleAdminChannel": { + "description": "Sample Admin (accountId=0) — posts operational progress/decisions via sampleAdminUpdate", + "type": "Coordination/Timeline Channel", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "event": { + "description": "Optional matcher payload used by the channel's processor to further restrict which incoming events it accepts at this scope." + }, + "timelineId": "admin-timeline", + "accountId": "0", + "email": { + "description": "Email address associated with the Sample timeline", + "type": "Text" + } + }, + "sampleAdminUpdate": { + "description": "The standard, required operation for Sample Admin to deliver events.", + "type": "Coordination/Operation", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "sampleAdminChannel", + "request": { + "description": "The request schema for this operation (any Blue node). Invocation payloads MUST conform to this shape.\n" + } + }, + "sampleAdminUpdateImpl": { + "description": "Implementation that re-emits the provided events", + "type": "Coordination/Sequential Workflow Operation", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": { + "description": "The contracts-map key of the channel this handler binds to (same scope).", + "type": "Text" + }, + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events." + }, + "steps": [ + { + "name": "EmitAdminEvents", + "type": "Coordination/Compute", + "emitEvents": true, + "returnResult": true, + "do": [ + { + "$return": { + "changeset": [ + + ], + "events": { + "$event": "/message/request" + } + } + } + ] + } + ], + "operation": "sampleAdminUpdate" + }, + "investorChannel": { + "type": "Coordination/Timeline Channel", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "event": { + "description": "Optional matcher payload used by the channel's processor to further restrict which incoming events it accepts at this scope." + }, + "timelineId": "investor-timeline", + "accountId": "investor-uid", + "email": { + "description": "Email address associated with the Sample timeline", + "type": "Text" + } + }, + "initLifecycleChannel": { + "type": "Lifecycle Event Channel", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "event": { + "description": "Optional matcher payload used by the channel's processor to further restrict which incoming events it accepts at this scope.", + "type": "Document Processing Initiated", + "documentId": { + "description": "Stable document identifier (original BlueId).", + "type": "Text" + } + } + }, + "triggeredEventChannel": { + "type": "Triggered Event Channel", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "event": { + "description": "Optional matcher payload used by the channel's processor to further restrict which incoming events it accepts at this scope." + } + }, + "sessionInteraction": { + "type": "Sample/Sample Session Interaction" + }, + "automationSection": { + "type": "Coordination/Document Section", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "relatedContracts": { + "description": "Contract keys in the same scope that implement or affect the section.", + "type": "List", + "itemType": "Text" + }, + "relatedFields": [ + "/description", + "/status", + "/state", + "/orders" + ], + "summary": { + "description": "Brief functional summary of the section's purpose and behavior.", + "type": "Text" + }, + "title": "Automation status" + }, + "orderLedgerSection": { + "type": "Coordination/Document Section", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "relatedContracts": { + "description": "Contract keys in the same scope that implement or affect the section.", + "type": "List", + "itemType": "Text" + }, + "relatedFields": [ + "/orders", + "/resaleOrderRequests" + ], + "summary": { + "description": "Brief functional summary of the section's purpose and behavior.", + "type": "Text" + }, + "title": "Projected orders" + }, + "requestSetupGrantsOnInit": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "initLifecycleChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events." + }, + "steps": [ + { + "name": "BuildPackageFulfillmentSetupRequests", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "buildPackageFulfillmentSetupRequests", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageSetupInvestorPaymentAccountGrant": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "sdpg:package:investor-payment-account:investor-payment-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": "investor-payment-session" + }, + "steps": [ + { + "name": "ProcessPackageSetupInvestorPaymentAccountGrant", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processSetupGrant", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageSetupHotelAgreementGrant": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "sdpg:package:hotel-agreement:hotel-agreement-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": "hotel-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageSetupHotelAgreementGrant", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processSetupGrant", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageSetupRestaurantAgreementGrant": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "sdpg:package:restaurant-agreement:restaurant-agreement-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": "restaurant-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageSetupRestaurantAgreementGrant", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processSetupGrant", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageOrderDiscovered": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:package-offer:orders:package-offer-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": { + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageOrderDiscovered", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processPackageOrderDiscovered", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageCustomerPayNoteDiscovered": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:package-offer:customer-paynotes:package-offer-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": { + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageCustomerPayNoteDiscovered", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processCustomerPayNoteDiscovered", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageOfferOrdersGrantReady": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Linked Documents Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:package-offer:orders:package-offer-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "links": { + "type": "Sample/Linked Documents Permission Set", + "keyType": "Text", + "valueType": "Sample/Single Document Permission Set" + }, + "targetSessionId": "package-offer-session" + }, + "steps": [ + { + "name": "ProcessPackageOfferOrdersGrantReady", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "markPackageOfferOrdersGrantReady", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageOfferCustomerPayNotesGrantReady": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Linked Documents Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:package-offer:customer-paynotes:package-offer-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "links": { + "type": "Sample/Linked Documents Permission Set", + "keyType": "Text", + "valueType": "Sample/Single Document Permission Set" + }, + "targetSessionId": "package-offer-session" + }, + "steps": [ + { + "name": "ProcessPackageOfferCustomerPayNotesGrantReady", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "markPackageOfferCustomerPayNotesGrantReady", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageHotelAgreementOrdersGrantReady": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Linked Documents Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:hotel-agreement:orders:hotel-agreement-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "links": { + "type": "Sample/Linked Documents Permission Set", + "keyType": "Text", + "valueType": "Sample/Single Document Permission Set" + }, + "targetSessionId": "hotel-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageHotelAgreementOrdersGrantReady", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "markHotelAgreementOrdersGrantReady", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageRestaurantAgreementOrdersGrantReady": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Linked Documents Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:restaurant-agreement:orders:restaurant-agreement-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "links": { + "type": "Sample/Linked Documents Permission Set", + "keyType": "Text", + "valueType": "Sample/Single Document Permission Set" + }, + "targetSessionId": "restaurant-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageRestaurantAgreementOrdersGrantReady", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "markRestaurantAgreementOrdersGrantReady", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackagePaymentTargetSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active." + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": "investor-payment-targets", + "targetSessionId": "investor-payment-session" + }, + "steps": [ + { + "name": "ProcessPackagePaymentTargetSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "markPaymentTargetSubscriptionReady", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageHotelAgreementSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active." + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": "hotel-resale-agreement", + "targetSessionId": "hotel-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageHotelAgreementSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processHotelAgreementSubscriptionInitiated", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageRestaurantAgreementSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active." + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": "restaurant-resale-agreement", + "targetSessionId": "restaurant-agreement-session" + }, + "steps": [ + { + "name": "ProcessPackageRestaurantAgreementSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processRestaurantAgreementSubscriptionInitiated", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageOrderSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active.", + "kind": "Package Order" + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": { + "description": "The subscription id that was initiated.", + "type": "Text" + }, + "targetSessionId": { + "description": "Session being observed.", + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageOrderSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processPackageOrderSubscriptionInitiated", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageComponentHotelSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active.", + "kind": "Order", + "context": { + "orderKind": "hotel" + } + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": { + "description": "The subscription id that was initiated.", + "type": "Text" + }, + "targetSessionId": { + "description": "Session being observed.", + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageComponentHotelSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processHotelComponentSubscriptionInitiated", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageComponentRestaurantSubscriptionInitiated": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription to Session Initiated", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "at": { + "description": "ISO 8601 timestamp when the subscription became active.", + "type": "Text" + }, + "document": { + "description": "The document state at the time the subscription became active.", + "kind": "Order", + "context": { + "orderKind": "restaurant" + } + }, + "epoch": { + "description": "The epoch number at which the subscription became active.", + "type": "Integer" + }, + "subscriptionId": { + "description": "The subscription id that was initiated.", + "type": "Text" + }, + "targetSessionId": { + "description": "Session being observed.", + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageComponentRestaurantSubscriptionInitiated", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processRestaurantComponentSubscriptionInitiated", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageCustomerPaymentTargetPrepared": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": "investor-payment-targets", + "targetSessionId": "investor-payment-session", + "update": { + "description": "The update (subscription event) from the target session.", + "type": "Sample/Payment Target Prepared", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "allowedPayer": { + "description": "Optional effective payer restriction echoed back to the caller.", + "type": "Sample/Sample User", + "accountId": { + "description": "Stable Sample user identifier.", + "type": "Text" + } + }, + "amount": { + "description": "Optional effective amount constraint echoed back to the caller.", + "type": "Integer" + }, + "context": { + "description": "Optional business-context reference.", + "documentId": { + "description": "Blue document id identifying the business document this payment is for.", + "type": "Text" + } + }, + "currency": { + "description": "Optional effective currency constraint echoed back to the caller.", + "type": "Common/Currency" + }, + "expectedPaynote": { + "description": "Optional effective PayNote matcher echoed back to the caller." + }, + "expiresAt": { + "description": "Optional expiry echoed back to the caller.", + "type": "Text" + }, + "recipient": { + "description": "Prepared recipient reference.", + "type": "Sample/Sample Balance Account", + "token": { + "description": "Opaque prepared recipient token.", + "type": "Text" + } + } + } + }, + "steps": [ + { + "name": "ProcessPackageCustomerPaymentTargetPrepared", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processCustomerPaymentTargetPrepared", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageHotelResaleOrderPlaced": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": "hotel-resale-agreement", + "targetSessionId": "hotel-agreement-session", + "update": { + "description": "The update (subscription event) from the target session.", + "type": "Coordination/Response", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "kind": "Resale Order Placed" + } + }, + "steps": [ + { + "name": "ProcessPackageHotelResaleOrderPlaced", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processHotelResaleOrderPlaced", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageRestaurantResaleOrderPlaced": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": "restaurant-resale-agreement", + "targetSessionId": "restaurant-agreement-session", + "update": { + "description": "The update (subscription event) from the target session.", + "type": "Coordination/Response", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "kind": "Resale Order Placed" + } + }, + "steps": [ + { + "name": "ProcessPackageRestaurantResaleOrderPlaced", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processRestaurantResaleOrderPlaced", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageCustomerPayNoteFundsSecured": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": { + "description": "The ID of the subscription.", + "type": "Text" + }, + "targetSessionId": { + "description": "The ID of the target session.", + "type": "Text" + }, + "update": { + "description": "The update (subscription event) from the target session.", + "type": "PayNote/Funds Secured", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "amountSecured": { + "type": "Integer" + } + } + }, + "steps": [ + { + "name": "ProcessPackageCustomerPayNoteFundsSecured", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processCustomerPayNoteFundsSecured", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageCustomerPayNoteCompleted": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": { + "description": "The ID of the subscription.", + "type": "Text" + }, + "targetSessionId": { + "description": "The ID of the target session.", + "type": "Text" + }, + "update": { + "description": "The update (subscription event) from the target session.", + "type": "PayNote/Payment Completed", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "amountCompleted": { + "type": "Integer" + } + } + }, + "steps": [ + { + "name": "ProcessPackageCustomerPayNoteCompleted", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processCustomerPayNoteCompleted", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageComponentPaymentTokenAttached": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": { + "description": "The ID of the subscription.", + "type": "Text" + }, + "targetSessionId": { + "description": "The ID of the target session.", + "type": "Text" + }, + "update": { + "description": "The update (subscription event) from the target session.", + "type": "Coordination/Event", + "kind": "Payment Token Attached" + } + }, + "steps": [ + { + "name": "ProcessPackageComponentPaymentTokenAttached", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processComponentPaymentTokenAttached", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageComponentOrderConfirmed": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Subscription Update", + "subscriptionId": { + "description": "The ID of the subscription.", + "type": "Text" + }, + "targetSessionId": { + "description": "The ID of the target session.", + "type": "Text" + }, + "update": { + "description": "The update (subscription event) from the target session.", + "type": "Coordination/Event", + "kind": "Order Confirmed" + } + }, + "steps": [ + { + "name": "ProcessPackageComponentOrderConfirmed", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processComponentOrderConfirmed", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageCustomerPayNoteSnapshotResolved": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Document Initial Snapshot Resolved", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "document": { + "description": "Initial snapshot of requested document session.", + "context": { + "paymentKind": "customer_package_purchase" + } + } + }, + "steps": [ + { + "name": "ProcessPackageCustomerPayNoteSnapshotResolved", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processCustomerPayNoteSnapshotResolved", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageHotelComponentSnapshotResolved": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Document Initial Snapshot Resolved", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "document": { + "description": "Initial snapshot of requested document session.", + "kind": "Order", + "context": { + "orderKind": "hotel" + } + } + }, + "steps": [ + { + "name": "ProcessPackageHotelComponentSnapshotResolved", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processHotelComponentSnapshotResolved", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageRestaurantComponentSnapshotResolved": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Document Initial Snapshot Resolved", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "document": { + "description": "Initial snapshot of requested document session.", + "kind": "Order", + "context": { + "orderKind": "restaurant" + } + } + }, + "steps": [ + { + "name": "ProcessPackageRestaurantComponentSnapshotResolved", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processRestaurantComponentSnapshotResolved", + "emitEvents": true, + "returnResult": true + } + ] + }, + "processPackageInitialSnapshotUnresolved": { + "type": "Coordination/Sequential Workflow", + "order": { + "description": "Deterministic sort key within a scope; missing ≡ 0.", + "type": "Integer" + }, + "channel": "triggeredEventChannel", + "event": { + "description": "Optional matcher payload used by the handler's processor to further restrict events.", + "type": "Sample/Document Initial Snapshot Unresolved", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "reason": { + "type": "Text" + } + }, + "steps": [ + { + "name": "ProcessPackageInitialSnapshotUnresolved", + "type": "Coordination/Compute", + "definition": "packageFulfillmentComputeDefinition", + "entry": "processInitialSnapshotUnresolved", + "emitEvents": true, + "returnResult": true + } + ] + }, + "initialized": { + "type": "Processing Initialized Marker", + "documentId": "Ej64x8GDWChQPMpZd4wv3NQCm8QLz9w5cttfvLnnzvRa" + }, + "checkpoint": { + "type": "Channel Event Checkpoint", + "lastEvents": { + "sampleAdminChannel": { + "type": "Coordination/Timeline Entry", + "actor": { + "description": "Actor attribution for the creator of this entry.", + "type": "Sample/Principal Actor", + "accountId": "0" + }, + "message": { + "description": "Entry payload (any Blue node), e.g., Chat Message or Status Change.", + "type": "Coordination/Operation Request", + "allowNewerVersion": { + "description": "Controls concurrent modification handling. When true, processes the operation on the latest document version even if it changed. When false, only processes if the document still has the same blueId as specified.", + "type": "Boolean" + }, + "document": { + "description": "Specifies the target document for the operation, typically containing the blueId of the document to operate on." + }, + "operation": "sampleAdminUpdate", + "request": [ + { + "type": "Sample/Single Document Permission Granted", + "inResponseTo": { + "type": { + "name": "Correlation", + "description": "A structured reference linking this response back to the original action and trigger.", + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": { + "description": "The 'requestId' from the specific Request event this is a response to.", + "type": "Text" + } + }, + "incomingEvent": { + "description": "An event which initiated the entire workflow. Normally just blueId of it." + }, + "requestId": "ldpg:package-offer:customer-paynotes:package-offer-session" + }, + "grantDocumentId": { + "description": "Optional. Stable handle of the created permission grant document. Required in request/response document-grant flows that later support self-revoke from the grantee document.", + "type": "Text" + }, + "permissions": { + "type": "Sample/Single Document Permission Set", + "allOps": { + "type": "Boolean" + }, + "read": true, + "share": { + "type": "Boolean" + }, + "singleOps": { + "type": "List", + "itemType": "Text" + } + }, + "targetSessionId": "customer-paynote-a" + } + ] + }, + "prevEntry": { + "description": "The previous entry in the timeline; omitted for the first entry." + }, + "source": { + "description": "Optional delivery mechanism describing how the request reached the timeline provider, typically using a Coordination/Source specialization." + }, + "timeline": { + "description": "The timeline this entry belongs to.", + "type": "Sample/Sample Timeline", + "timelineId": "admin-timeline", + "accountId": { + "description": "Identifier for the Sample account associated with this timeline", + "type": "Text" + } + }, + "timestamp": 1700000000000 + } + }, + "lastSignatures": { + "sampleAdminChannel": "2q7QUJFicXL8GpAg2GCbEdLiox7ybtSu15Guezrd4HKy" + } + }, + "packageFulfillmentComputeDefinition": { + "type": "Coordination/Compute Definition", + "constants": { + "expectedPackageAmount": 100000, + "hotelAmountMinor": 54000, + "restaurantAmountMinor": 18000, + "packageLinkedSubscriptionPrefix": "package-linked:", + "agreementLinkedSubscriptionPrefix": "agreement-linked:", + "customerPayNoteSnapshotPrefix": "snapshot:customer-paynote:", + "hotelComponentSnapshotPrefix": "snapshot:component:hotel:", + "restaurantComponentSnapshotPrefix": "snapshot:component:restaurant:" + }, + "functions": { + "emptyComponentOrderState": { + "do": [ + { + "$return": { + "sessionId": "", + "documentId": "", + "resaleRequestId": "", + "resaleRequested": false, + "resalePlaced": false, + "snapshotRequestId": "", + "subscriptionId": "", + "attachedToPackageOrder": false, + "attachedToPayNote": false, + "merchantPaymentInitiated": false, + "confirmed": false + } + } + ] + }, + "defaultOrderState": { + "args": { + "sessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$return": { + "packageOrder": { + "sessionId": { + "$var": "sessionId" + }, + "documentId": "", + "customerAccountId": "", + "subscriptionId": { + "$concat": [ + { + "$const": "packageLinkedSubscriptionPrefix" + }, + { + "$var": "sessionId" + } + ] + }, + "observed": false, + "confirmed": false + }, + "customerPayment": { + "tokenRequestId": { + "$concat": [ + "reseller-weekend-package-customer-token:", + { + "$var": "sessionId" + } + ] + }, + "tokenRequested": false, + "tokenAttached": false + }, + "customerPayNote": { + "sessionId": "", + "snapshotRequestId": "", + "subscriptionId": "", + "attachedToPackageOrder": false, + "secured": false, + "securedAmount": 0, + "completed": false + }, + "hotelOrder": { + "$call": { + "function": "emptyComponentOrderState", + "args": { + } + } + }, + "restaurantOrder": { + "$call": { + "function": "emptyComponentOrderState", + "args": { + } + } + } + } + } + ] + }, + "initializedDocumentId": { + "args": { + "snapshot": { + } + }, + "do": [ + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "snapshot" + } + } + } + }, + { + "$let": { + "name": "initialized", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts", + "default": { + } + } + }, + "path": "/initialized", + "default": { + } + } + } + } + } + }, + { + "$return": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "initialized" + }, + "path": "/documentId", + "default": "" + } + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "initialized" + }, + "path": "/originalDocument", + "default": { + } + } + }, + "path": "/blueId", + "default": "" + } + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/documentId", + "default": "" + } + } + }, + "" + ] + } + } + ] + }, + "isCustomerPackagePayNoteSnapshot": { + "args": { + "snapshot": { + } + }, + "do": [ + { + "$return": { + "$eq": [ + { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$object": { + "$var": "snapshot" + } + }, + "path": "/context", + "default": { + } + } + }, + "path": "/paymentKind", + "default": "" + } + } + }, + "customer_package_purchase" + ] + } + } + ] + }, + "appendChangeIfChanged": { + "args": { + "path": { + "type": "Text" + }, + "val": { + } + }, + "do": [ + { + "$let": { + "name": "pathText", + "expr": { + "$text": { + "$var": "path" + } + } + } + }, + { + "$let": { + "name": "current", + "expr": { + "$resultValue": { + "path": { + "$var": "pathText" + } + } + } + } + }, + { + "$if": { + "cond": { + "$ne": [ + { + "$var": "current" + }, + { + "$var": "val" + } + ] + }, + "then": [ + { + "$appendChange": { + "op": "replace", + "path": { + "$var": "pathText" + }, + "val": { + "$var": "val" + } + } + } + ] + } + } + ] + }, + "ensureOrderLedger": { + "args": { + "sessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "sessionId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "pkg", + "expr": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder" + ] + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$object": { + "$var": "pkg" + } + } + }, + "then": [ + { + "$appendChange": { + "op": "add", + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + } + ] + }, + "val": { + "$call": { + "function": "defaultOrderState", + "args": { + "sessionId": { + "$var": "sessionId" + } + } + } + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "orderFieldRelativePath": { + "args": { + "key": { + "type": "Text" + } + }, + "do": [ + { + "$return": { + "$pointerGet": { + "object": { + "packageOrderSessionId": "/packageOrder/sessionId", + "packageOrderDocumentId": "/packageOrder/documentId", + "customerAccountId": "/packageOrder/customerAccountId", + "packageConfirmed": "/packageOrder/confirmed", + "customerPaymentTokenRequested": "/customerPayment/tokenRequested", + "customerPaymentTokenAttached": "/customerPayment/tokenAttached", + "packagePayNoteSessionId": "/customerPayNote/sessionId", + "packagePayNoteAttached": "/customerPayNote/attachedToPackageOrder", + "packagePayNoteSecured": "/customerPayNote/secured", + "packagePayNoteSecuredAmount": "/customerPayNote/securedAmount", + "packagePayNoteCompleted": "/customerPayNote/completed", + "hotelComponentRejected": "/hotelOrder/rejectionReason", + "restaurantComponentRejected": "/restaurantOrder/rejectionReason" + }, + "path": { + "$concat": [ + "/", + { + "$text": { + "$var": "key" + } + } + ] + }, + "default": "" + } + } + } + ] + }, + "orderObjectFieldRelativePath": { + "args": { + "field": { + "type": "Text" + }, + "kind": { + "type": "Text" + } + }, + "do": [ + { + "$return": { + "$pointerGet": { + "object": { + "componentOrderSessions": { + "hotel": "/hotelOrder/sessionId", + "restaurant": "/restaurantOrder/sessionId" + }, + "componentOrderDocumentIds": { + "hotel": "/hotelOrder/documentId", + "restaurant": "/restaurantOrder/documentId" + }, + "componentOrderAttached": { + "hotel": "/hotelOrder/attachedToPackageOrder", + "restaurant": "/restaurantOrder/attachedToPackageOrder" + }, + "componentOrderAttachedToPayNote": { + "hotel": "/hotelOrder/attachedToPayNote", + "restaurant": "/restaurantOrder/attachedToPayNote" + }, + "componentOrderConfirmed": { + "hotel": "/hotelOrder/confirmed", + "restaurant": "/restaurantOrder/confirmed" + }, + "merchantPaymentInitiated": { + "hotel": "/hotelOrder/merchantPaymentInitiated", + "restaurant": "/restaurantOrder/merchantPaymentInitiated" + }, + "resaleOrderPlaced": { + "hotel": "/hotelOrder/resalePlaced", + "restaurant": "/restaurantOrder/resalePlaced" + }, + "resaleOrderRequested": { + "hotel": "/hotelOrder/resaleRequested", + "restaurant": "/restaurantOrder/resaleRequested" + }, + "resaleOrderRequestIds": { + "hotel": "/hotelOrder/resaleRequestId", + "restaurant": "/restaurantOrder/resaleRequestId" + }, + "componentSnapshotRequestIds": { + "hotel": "/hotelOrder/snapshotRequestId", + "restaurant": "/restaurantOrder/snapshotRequestId" + }, + "componentSubscriptionIds": { + "hotel": "/hotelOrder/subscriptionId", + "restaurant": "/restaurantOrder/subscriptionId" + } + }, + "path": { + "$concat": [ + "/", + { + "$text": { + "$var": "field" + } + }, + "/", + { + "$text": { + "$var": "kind" + } + } + ] + }, + "default": "" + } + } + } + ] + }, + "setOrderPath": { + "args": { + "sessionId": { + "type": "Text" + }, + "relativePath": { + "type": "Text" + }, + "val": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "sessionId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "ensureOrderLedger", + "args": { + "sessionId": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "pathText", + "expr": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + { + "$text": { + "$var": "relativePath" + } + } + ] + } + } + }, + { + "$if": { + "cond": { + "$ne": [ + { + "$resultValue": { + "path": { + "$var": "pathText" + } + } + }, + { + "$var": "val" + } + ] + }, + "then": [ + { + "$appendChange": { + "op": "add", + "path": { + "$var": "pathText" + }, + "val": { + "$var": "val" + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "setOrderField": { + "args": { + "sessionId": { + "type": "Text" + }, + "key": { + "type": "Text" + }, + "val": { + } + }, + "do": [ + { + "$let": { + "name": "relativePath", + "expr": { + "$call": { + "function": "orderFieldRelativePath", + "args": { + "key": { + "$var": "key" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "relativePath" + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "relativePath": { + "$var": "relativePath" + }, + "val": { + "$var": "val" + } + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "mergeOrderObjectField": { + "args": { + "sessionId": { + "type": "Text" + }, + "key": { + "type": "Text" + }, + "patch": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$forEach": { + "in": { + "$entries": { + "$object": { + "$var": "patch" + } + } + }, + "item": "entry", + "do": [ + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "entry" + }, + "path": "/key", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "relativePath", + "expr": { + "$call": { + "function": "orderObjectFieldRelativePath", + "args": { + "field": { + "$var": "key" + }, + "kind": { + "$var": "kind" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "relativePath" + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "relativePath": { + "$var": "relativePath" + }, + "val": { + "$pointerGet": { + "object": { + "$var": "entry" + }, + "path": "/val" + } + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "markMatchingSetupGrant": { + "args": { + "targetSessionId": { + "type": "Text" + }, + "expectedSessionId": { + "type": "Text" + }, + "statePath": { + "type": "Text" + } + }, + "do": [ + { + "$if": { + "cond": { + "$eq": [ + { + "$text": { + "$var": "targetSessionId" + } + }, + { + "$text": { + "$var": "expectedSessionId" + } + } + ] + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": { + "$var": "statePath" + }, + "val": true + } + } + } + ] + } + } + ] + }, + "setupGrantValue": { + "args": { + "path": { + "type": "Text" + } + }, + "do": [ + { + "$return": { + "$boolean": { + "$resultValue": { + "path": { + "$var": "path" + } + } + } + } + } + ] + }, + "maybeMarkGrantsReady": { + "do": [ + { + "$if": { + "cond": { + "$and": [ + { + "$boolean": { + "$resultValue": "/state/setupGrants/investorPaymentAccount" + } + }, + { + "$boolean": { + "$resultValue": "/state/setupGrants/hotelAgreement" + } + }, + { + "$boolean": { + "$resultValue": "/state/setupGrants/restaurantAgreement" + } + }, + { + "$boolean": { + "$resultValue": "/state/packageOfferLdpgReady" + } + }, + { + "$boolean": { + "$resultValue": "/state/customerPayNotesLdpgReady" + } + }, + { + "$boolean": { + "$resultValue": "/state/hotelOrdersLdpgReady" + } + }, + { + "$boolean": { + "$resultValue": "/state/restaurantOrdersLdpgReady" + } + }, + { + "$not": { + "$boolean": { + "$resultValue": "/state/grantsReady" + } + } + } + ] + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/grantsReady", + "val": true + } + } + }, + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/status", + "val": "active" + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "maybeSubscribeSetup": { + "do": [ + { + "$if": { + "cond": { + "$boolean": { + "$resultValue": "/state/grantsReady" + } + }, + "then": [ + { + "$if": { + "cond": { + "$not": { + "$boolean": { + "$resultValue": "/state/paymentTokenSubscriptionRequested" + } + } + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/paymentTokenSubscriptionRequested", + "val": true + } + } + }, + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "targetSessionId": { + "$document": "/investorPaymentAccountSessionId" + }, + "subscription": { + "id": "investor-payment-targets", + "events": [ + { + "type": "Sample/Payment Target Prepared" + }, + { + "type": "Sample/Payment Target Preparation Failed" + } + ] + } + } + } + ] + } + }, + { + "$if": { + "cond": { + "$not": { + "$boolean": { + "$resultValue": "/state/agreementSubscriptionsRequested" + } + } + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/agreementSubscriptionsRequested", + "val": true + } + } + }, + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "targetSessionId": { + "$document": "/hotelAgreementSessionId" + }, + "subscription": { + "id": "hotel-resale-agreement", + "events": [ + { + "type": "Coordination/Response", + "kind": "Resale Order Placed" + } + ] + } + } + }, + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "targetSessionId": { + "$document": "/restaurantAgreementSessionId" + }, + "subscription": { + "id": "restaurant-resale-agreement", + "events": [ + { + "type": "Coordination/Response", + "kind": "Resale Order Placed" + } + ] + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "processSetupGrant": { + "do": [ + { + "$let": { + "name": "targetSessionId", + "expr": { + "$text": { + "$event": "/targetSessionId" + } + } + } + }, + { + "$call": { + "function": "markMatchingSetupGrant", + "args": { + "targetSessionId": { + "$var": "targetSessionId" + }, + "expectedSessionId": { + "$document": "/investorPaymentAccountSessionId" + }, + "statePath": "/state/setupGrants/investorPaymentAccount" + } + } + }, + { + "$call": { + "function": "markMatchingSetupGrant", + "args": { + "targetSessionId": { + "$var": "targetSessionId" + }, + "expectedSessionId": { + "$document": "/hotelAgreementSessionId" + }, + "statePath": "/state/setupGrants/hotelAgreement" + } + } + }, + { + "$call": { + "function": "markMatchingSetupGrant", + "args": { + "targetSessionId": { + "$var": "targetSessionId" + }, + "expectedSessionId": { + "$document": "/restaurantAgreementSessionId" + }, + "statePath": "/state/setupGrants/restaurantAgreement" + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "markPackageOfferOrdersGrantReady": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/packageOfferLdpgReady", + "val": true + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "markPackageOfferCustomerPayNotesGrantReady": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/customerPayNotesLdpgReady", + "val": true + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "markHotelAgreementOrdersGrantReady": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/hotelOrdersLdpgReady", + "val": true + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "markRestaurantAgreementOrdersGrantReady": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/restaurantOrdersLdpgReady", + "val": true + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "markPaymentTargetSubscriptionReady": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/paymentTokenSubscriptionReady", + "val": true + } + } + }, + { + "$call": { + "function": "maybeMarkGrantsReady", + "args": { + } + } + }, + { + "$call": { + "function": "maybeSubscribeSetup", + "args": { + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processPackageOrderDiscovered": { + "do": [ + { + "$let": { + "name": "targetSessionId", + "expr": { + "$text": { + "$event": "/targetSessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "targetSessionId" + } + } + }, + "then": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$concat": [ + { + "$const": "packageLinkedSubscriptionPrefix" + }, + { + "$var": "targetSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "wasObserved", + "expr": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "targetSessionId" + }, + "/packageOrder/observed" + ] + } + } + } + } + } + }, + { + "$call": { + "function": "ensureOrderLedger", + "args": { + "sessionId": { + "$var": "targetSessionId" + } + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "targetSessionId" + }, + "relativePath": "/packageOrder/observed", + "val": true + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "targetSessionId" + }, + "relativePath": "/packageOrder/subscriptionId", + "val": { + "$var": "subscriptionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "wasObserved" + } + }, + "then": [ + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "targetSessionId": { + "$var": "targetSessionId" + }, + "subscription": { + "id": { + "$var": "subscriptionId" + }, + "events": [ + + ] + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processCustomerPayNoteDiscovered": { + "do": [ + { + "$let": { + "name": "targetSessionId", + "expr": { + "$text": { + "$event": "/targetSessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "targetSessionId" + } + } + }, + "then": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$concat": [ + { + "$const": "packageLinkedSubscriptionPrefix" + }, + { + "$var": "targetSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "snapshotRequestId", + "expr": { + "$concat": [ + { + "$const": "customerPayNoteSnapshotPrefix" + }, + { + "$var": "targetSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "existingSession", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "targetSessionId" + }, + "/sessionId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "existingSession" + } + }, + "then": [ + { + "$appendChange": { + "op": "add", + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "targetSessionId" + } + ] + }, + "val": { + "sessionId": { + "$var": "targetSessionId" + }, + "packageOrderSessionId": "", + "packageOrderDocumentId": "", + "snapshotRequestId": { + "$var": "snapshotRequestId" + }, + "subscriptionId": { + "$var": "subscriptionId" + } + } + } + } + ] + } + }, + { + "$let": { + "name": "existingSnapshotRequestId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "targetSessionId" + }, + "/snapshotRequestId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "existingSnapshotRequestId" + } + }, + "then": [ + { + "$appendEvent": { + "type": "Sample/Document Initial Snapshot Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "targetSessionId" + }, + "sourceSessionId": { + "$var": "targetSessionId" + }, + "requestId": { + "$var": "snapshotRequestId" + } + } + }, + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "targetSessionId": { + "$var": "targetSessionId" + }, + "subscription": { + "id": { + "$var": "subscriptionId" + }, + "events": [ + { + "type": "PayNote/Funds Secured" + }, + { + "type": "PayNote/Payment Completed" + } + ] + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processAgreementSnapshot": { + "args": { + "agreementKind": { + "type": "Text" + }, + "agreementSnapshot": { + } + }, + "do": [ + { + "$let": { + "name": "agreementKind", + "expr": { + "$text": { + "$var": "agreementKind" + } + } + } + }, + { + "$let": { + "name": "agreementSnapshot", + "expr": { + "$object": { + "$var": "agreementSnapshot" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "agreementKind" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$forEach": { + "in": { + "$entries": { + "$object": { + "$document": "/resaleOrderRequests" + } + } + }, + "item": "requestEntry", + "do": [ + { + "$let": { + "name": "responseRequestId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "requestEntry" + }, + "path": "/key", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "request", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "requestEntry" + }, + "path": "/val", + "default": { + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$eq": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "request" + }, + "path": "/kind", + "default": "" + } + } + }, + { + "$var": "agreementKind" + } + ] + }, + "then": [ + { + "$let": { + "name": "placed", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "agreementSnapshot" + }, + "path": "/orders", + "default": { + } + } + }, + "path": { + "$concat": [ + "/", + { + "$var": "responseRequestId" + } + ] + }, + "default": { + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$eq": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "placed" + }, + "path": "/status", + "default": "" + } + } + }, + "placed" + ] + }, + "then": [ + { + "$let": { + "name": "orderSessionId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "placed" + }, + "path": "/orderSessionId", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "orderSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "recordPlacedResaleOrder", + "args": { + "agreementKind": { + "$var": "agreementKind" + }, + "responseRequestId": { + "$var": "responseRequestId" + }, + "orderSessionId": { + "$var": "orderSessionId" + } + } + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "processHotelAgreementSubscriptionInitiated": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/hotelAgreementSubscriptionReady", + "val": true + } + } + }, + { + "$call": { + "function": "processAgreementSnapshot", + "args": { + "agreementKind": "hotel", + "agreementSnapshot": { + "$event": "/document" + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processRestaurantAgreementSubscriptionInitiated": { + "do": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/state/restaurantAgreementSubscriptionReady", + "val": true + } + } + }, + { + "$call": { + "function": "processAgreementSnapshot", + "args": { + "agreementKind": "restaurant", + "agreementSnapshot": { + "$event": "/document" + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processPackageOrderSubscriptionInitiated": { + "do": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$text": { + "$event": "/subscriptionId" + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + }, + "then": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$sliceAfter": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "packageOrderSessionId" + } + ] + } + ] + }, + "then": [ + { + "$call": { + "function": "processSnapshot", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "snapshot": { + "$event": "/document" + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processComponentSubscriptionUpdate": { + "args": { + "agreementKind": { + "type": "Text" + }, + "prefix": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$text": { + "$event": "/subscriptionId" + } + } + } + }, + { + "$let": { + "name": "prefix", + "expr": { + "$text": { + "$var": "prefix" + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "subscriptionId" + }, + { + "$var": "prefix" + } + ] + }, + "then": [ + { + "$let": { + "name": "componentSessionId", + "expr": { + "$sliceAfter": [ + { + "$var": "subscriptionId" + }, + { + "$var": "prefix" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "componentSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "componentSessionId" + } + ] + } + ] + }, + "then": [ + { + "$call": { + "function": "processSnapshot", + "args": { + "sessionId": { + "$var": "componentSessionId" + }, + "snapshot": { + "$event": "/document" + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "processHotelComponentSubscriptionInitiated": { + "do": [ + { + "$call": { + "function": "processComponentSubscriptionUpdate", + "args": { + "agreementKind": "hotel", + "prefix": { + "$concat": [ + { + "$const": "agreementLinkedSubscriptionPrefix" + }, + "hotel:" + ] + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processRestaurantComponentSubscriptionInitiated": { + "do": [ + { + "$call": { + "function": "processComponentSubscriptionUpdate", + "args": { + "agreementKind": "restaurant", + "prefix": { + "$concat": [ + { + "$const": "agreementLinkedSubscriptionPrefix" + }, + "restaurant:" + ] + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processCustomerPaymentTargetPrepared": { + "do": [ + { + "$let": { + "name": "token", + "expr": { + "$text": { + "$event": "/update/recipient/token" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "token" + } + } + }, + "then": [ + { + "$let": { + "name": "tokenRequestId", + "expr": { + "$coalesce": [ + { + "$event": "/update/inResponseTo/requestId" + }, + { + "$event": "/inResponseTo/requestId" + }, + { + "$event": "/update/requestId" + } + ] + } + } + }, + { + "$call": { + "function": "recordCustomerPaymentToken", + "args": { + "requestId": { + "$var": "tokenRequestId" + }, + "token": { + "$var": "token" + } + } + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processHotelResaleOrderPlaced": { + "do": [ + { + "$call": { + "function": "recordPlacedResaleOrder", + "args": { + "agreementKind": "hotel", + "responseRequestId": { + "$text": { + "$event": "/update/inResponseTo/requestId" + } + }, + "orderSessionId": { + "$text": { + "$event": "/update/orderSessionId" + } + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processRestaurantResaleOrderPlaced": { + "do": [ + { + "$call": { + "function": "recordPlacedResaleOrder", + "args": { + "agreementKind": "restaurant", + "responseRequestId": { + "$text": { + "$event": "/update/inResponseTo/requestId" + } + }, + "orderSessionId": { + "$text": { + "$event": "/update/orderSessionId" + } + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "parseAgreementLinkedSubscription": { + "do": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$text": { + "$event": "/subscriptionId" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$startsWith": [ + { + "$var": "subscriptionId" + }, + { + "$const": "agreementLinkedSubscriptionPrefix" + } + ] + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "tail", + "expr": { + "$sliceAfter": [ + { + "$var": "subscriptionId" + }, + { + "$const": "agreementLinkedSubscriptionPrefix" + } + ] + } + } + }, + { + "$let": { + "name": "parts", + "expr": { + "$split": { + "text": { + "$var": "tail" + }, + "separator": ":" + } + } + } + }, + { + "$let": { + "name": "orderKind", + "expr": { + "$text": { + "$listGet": { + "list": { + "$var": "parts" + }, + "index": 0, + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$text": { + "$listGet": { + "list": { + "$var": "parts" + }, + "index": 1, + "default": "" + } + } + } + } + }, + { + "$return": { + "orderKind": { + "$var": "orderKind" + }, + "componentSessionId": { + "$var": "componentSessionId" + } + } + } + ] + }, + "findPackageOrderByComponentSession": { + "args": { + "kind": { + "type": "Text" + }, + "componentSessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$var": "kind" + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$text": { + "$var": "componentSessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "kind" + } + }, + { + "$not": { + "$var": "componentSessionId" + } + } + ] + }, + "then": [ + { + "$return": "" + } + ] + } + }, + { + "$let": { + "name": "ref", + "expr": { + "$object": { + "$resultValue": { + "path": { + "$concat": [ + "/componentOrderRefsBySessionId/", + { + "$var": "componentSessionId" + } + ] + } + } + } + } + } + }, + { + "$let": { + "name": "component", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "ref" + }, + "path": "/component", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "component" + } + } + }, + { + "$ne": [ + { + "$var": "component" + }, + { + "$concat": [ + { + "$var": "kind" + }, + "Order" + ] + } + ] + } + ] + }, + "then": [ + { + "$return": "" + } + ] + } + }, + { + "$return": { + "$text": { + "$pointerGet": { + "object": { + "$var": "ref" + }, + "path": "/packageOrderSessionId", + "default": "" + } + } + } + } + ] + }, + "processComponentPaymentTokenAttached": { + "do": [ + { + "$let": { + "name": "parsed", + "expr": { + "$call": { + "function": "parseAgreementLinkedSubscription", + "args": { + } + } + } + } + }, + { + "$let": { + "name": "orderKind", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "parsed" + }, + "path": "/orderKind", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "parsed" + }, + "path": "/componentSessionId", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "componentSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "componentSessionId" + } + ] + } + ] + }, + "then": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$call": { + "function": "findPackageOrderByComponentSession", + "args": { + "kind": { + "$var": "orderKind" + }, + "componentSessionId": { + "$var": "componentSessionId" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + "then": [ + { + "$let": { + "name": "token", + "expr": { + "$text": { + "$event": "/update/paymentToken" + } + } + } + }, + { + "$call": { + "function": "maybePayMerchantForToken", + "args": { + "kind": { + "$var": "orderKind" + }, + "componentSessionId": { + "$var": "componentSessionId" + }, + "token": { + "$var": "token" + }, + "orderSnapshot": { + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processComponentOrderConfirmed": { + "do": [ + { + "$let": { + "name": "parsed", + "expr": { + "$call": { + "function": "parseAgreementLinkedSubscription", + "args": { + } + } + } + } + }, + { + "$let": { + "name": "orderKind", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "parsed" + }, + "path": "/orderKind", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "parsed" + }, + "path": "/componentSessionId", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "componentSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "componentSessionId" + } + ] + } + ] + }, + "then": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$call": { + "function": "findPackageOrderByComponentSession", + "args": { + "kind": { + "$var": "orderKind" + }, + "componentSessionId": { + "$var": "componentSessionId" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderConfirmed", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "orderKind" + }, + "val": true + } + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processCustomerPayNoteFundsSecured": { + "do": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$text": { + "$event": "/subscriptionId" + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + }, + "then": [ + { + "$let": { + "name": "targetSessionId", + "expr": { + "$sliceAfter": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "targetSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "targetSessionId" + } + ] + } + ] + }, + "then": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "targetSessionId" + }, + "/packageOrderSessionId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + "then": [ + { + "$let": { + "name": "securedAmount", + "expr": { + "$integer": { + "$coalesce": [ + { + "$event": "/update/amountSecured" + }, + { + "$event": "/update/amount" + }, + { + "$const": "expectedPackageAmount" + } + ] + } + } + } + }, + { + "$call": { + "function": "markPackagePayNoteSecured", + "args": { + "packageOrderSessionId": { + "$var": "packageOrderSessionId" + }, + "amountSecured": { + "$var": "securedAmount" + } + } + } + } + ] + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processCustomerPayNoteCompleted": { + "do": [ + { + "$let": { + "name": "subscriptionId", + "expr": { + "$text": { + "$event": "/subscriptionId" + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + }, + "then": [ + { + "$let": { + "name": "targetSessionId", + "expr": { + "$sliceAfter": [ + { + "$var": "subscriptionId" + }, + { + "$const": "packageLinkedSubscriptionPrefix" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "targetSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$event": "/targetSessionId" + } + }, + { + "$var": "targetSessionId" + } + ] + } + ] + }, + "then": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "targetSessionId" + }, + "/packageOrderSessionId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "packagePayNoteCompleted", + "val": true + } + } + } + ] + } + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processCustomerPayNoteSnapshotResolved": { + "do": [ + { + "$let": { + "name": "snapshotRequestId", + "expr": { + "$text": { + "$coalesce": [ + { + "$event": "/inResponseTo/requestId" + }, + { + "$event": "/requestId" + } + ] + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "snapshotRequestId" + }, + { + "$const": "customerPayNoteSnapshotPrefix" + } + ] + }, + "then": [ + { + "$call": { + "function": "processCustomerPayNoteInitialSnapshot", + "args": { + "payNoteSessionId": { + "$sliceAfter": [ + { + "$var": "snapshotRequestId" + }, + { + "$const": "customerPayNoteSnapshotPrefix" + } + ] + }, + "snapshot": { + "$event": "/document" + } + } + } + } + ] + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processComponentSnapshotResolved": { + "args": { + "prefix": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "snapshotRequestId", + "expr": { + "$text": { + "$coalesce": [ + { + "$event": "/inResponseTo/requestId" + }, + { + "$event": "/requestId" + } + ] + } + } + } + }, + { + "$let": { + "name": "prefix", + "expr": { + "$text": { + "$var": "prefix" + } + } + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "snapshotRequestId" + }, + { + "$var": "prefix" + } + ] + }, + "then": [ + { + "$call": { + "function": "processSnapshot", + "args": { + "sessionId": { + "$sliceAfter": [ + { + "$var": "snapshotRequestId" + }, + { + "$var": "prefix" + } + ] + }, + "snapshot": { + "$event": "/document" + } + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "processHotelComponentSnapshotResolved": { + "do": [ + { + "$call": { + "function": "processComponentSnapshotResolved", + "args": { + "prefix": { + "$const": "hotelComponentSnapshotPrefix" + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processRestaurantComponentSnapshotResolved": { + "do": [ + { + "$call": { + "function": "processComponentSnapshotResolved", + "args": { + "prefix": { + "$const": "restaurantComponentSnapshotPrefix" + } + } + } + }, + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "processInitialSnapshotUnresolved": { + "do": [ + { + "$return": { + "changeset": { + "$changeset": true + }, + "events": { + "$events": true + } + } + } + ] + }, + "buildCheckoutContext": { + "args": { + "orderSessionId": { + "type": "Text" + }, + "orderDocumentId": { + "type": "Text" + }, + "customerAccountId": { + "type": "Text" + }, + "investorAccountId": { + "type": "Text" + } + }, + "do": [ + { + "$return": { + "customerAccountId": { + "$var": "customerAccountId" + }, + "investorAccountId": { + "$var": "investorAccountId" + }, + "packageOrderDocumentId": { + "$var": "orderDocumentId" + } + } + } + ] + }, + "buildPackagePayNoteDescriptor": { + "args": { + "context": { + } + }, + "do": [ + { + "$let": { + "name": "context", + "expr": { + "$object": { + "$var": "context" + } + } + } + }, + { + "$return": { + "document": { + "name": "Customer to Boutique Travel Agency Package PayNote", + "type": "PayNote/PayNote", + "kind": "PayNote", + "description": "Customer package payment secured before provider orders.", + "payNoteInitialStateDescription": { + "summary": "Payment for the Weekend Stay + Wine Dinner package.", + "details": "This PayNote secures the customer's package payment to Boutique Travel Agency. The payment is completed only after both included merchant orders are confirmed: Hotel Aurora confirms the weekend stay order and Restaurant Lumi confirms the wine dinner order. Once both confirmations are present, the package payment is completed and the package becomes ready to use." + }, + "state": "not_started", + "currency": "USD", + "amount": { + "expectedTotal": { + "$const": "expectedPackageAmount" + } + }, + "context": { + "scenario": "reseller-weekend-package", + "paymentKind": "customer_package_purchase", + "packageOrderDocumentId": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/packageOrderDocumentId", + "default": "" + } + }, + "packagePayNoteSessionId": "", + "packagePayNoteDocumentId": "" + }, + "embeddedDocs": { + }, + "completionRequested": false, + "contracts": { + "payerChannel": { + "type": "Coordination/Timeline Channel" + }, + "payeeChannel": { + "type": "Coordination/Timeline Channel" + }, + "guarantorChannel": { + "type": "Coordination/Timeline Channel" + }, + "links": { + "type": "Sample/Document Links", + "packageOrder": { + "type": "Sample/Document Link", + "documentId": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/packageOrderDocumentId", + "default": "" + } + }, + "anchor": "payments" + }, + "packageOffer": { + "type": "Sample/Document Link", + "documentId": { + "$document": "/packageOfferDocumentId" + }, + "anchor": "customerPayNotes" + } + }, + "embeddedHotelOrderEvents": { + "type": "Embedded Node Channel", + "childPath": "/embeddedDocs/hotelOrder" + }, + "embeddedRestaurantOrderEvents": { + "type": "Embedded Node Channel", + "childPath": "/embeddedDocs/restaurantOrder" + }, + "processEmbeddedComponentOrders": { + "type": "Process Embedded", + "paths": [ + "/embeddedDocs/hotelOrder", + "/embeddedDocs/restaurantOrder" + ] + }, + "completeWhenOrdersConfirmedFromHotelEvent": { + "type": "Coordination/Sequential Workflow", + "channel": "embeddedHotelOrderEvents", + "event": { + "type": "Coordination/Event", + "kind": "Order Confirmed" + }, + "steps": [ + { + "name": "BuildCompletion", + "type": "Coordination/Compute", + "emitEvents": true, + "returnResult": true, + "do": [ + { + "$if": { + "cond": { + "$or": [ + { + "$ne": [ + { + "$text": { + "$document": "/embeddedDocs/hotelOrder/confirmation/status" + } + }, + "confirmed" + ] + }, + { + "$ne": [ + { + "$text": { + "$document": "/embeddedDocs/restaurantOrder/confirmation/status" + } + }, + "confirmed" + ] + }, + { + "$boolean": { + "$document": "/completionRequested" + } + } + ] + }, + "then": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + + ] + } + } + ] + } + }, + { + "$return": { + "changeset": [ + { + "op": "replace", + "path": "/completionRequested", + "val": true + } + ], + "events": [ + { + "type": "PayNote/Complete Payment Requested", + "amount": { + "$const": "expectedPackageAmount" + } + } + ] + } + } + ] + } + ] + }, + "completeWhenOrdersConfirmedFromRestaurantEvent": { + "type": "Coordination/Sequential Workflow", + "channel": "embeddedRestaurantOrderEvents", + "event": { + "type": "Coordination/Event", + "kind": "Order Confirmed" + }, + "steps": [ + { + "name": "BuildCompletion", + "type": "Coordination/Compute", + "emitEvents": true, + "returnResult": true, + "do": [ + { + "$if": { + "cond": { + "$or": [ + { + "$ne": [ + { + "$text": { + "$document": "/embeddedDocs/hotelOrder/confirmation/status" + } + }, + "confirmed" + ] + }, + { + "$ne": [ + { + "$text": { + "$document": "/embeddedDocs/restaurantOrder/confirmation/status" + } + }, + "confirmed" + ] + }, + { + "$boolean": { + "$document": "/completionRequested" + } + } + ] + }, + "then": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + + ] + } + } + ] + } + }, + { + "$return": { + "changeset": [ + { + "op": "replace", + "path": "/completionRequested", + "val": true + } + ], + "events": [ + { + "type": "PayNote/Complete Payment Requested", + "amount": { + "$const": "expectedPackageAmount" + } + } + ] + } + } + ] + } + ] + }, + "attachComponentOrder": { + "type": "Coordination/Operation", + "description": "Attaches an included merchant order snapshot so package payment can complete after both confirmations.", + "channel": "payeeChannel", + "request": { + "kind": { + "type": "Text" + }, + "initialSnapshot": { + "type": "Common/Record" + } + } + }, + "attachComponentOrderImpl": { + "type": "Coordination/Sequential Workflow Operation", + "operation": "attachComponentOrder", + "steps": [ + { + "name": "BuildComponentAttachment", + "type": "Coordination/Compute", + "emitEvents": true, + "returnResult": true, + "do": [ + { + "$let": { + "name": "req", + "expr": { + "$object": { + "$event": "/message/request" + } + } + } + }, + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$unwrap": { + "$pointerGet": { + "object": { + "$var": "req" + }, + "path": "/kind", + "default": "" + } + } + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "req" + }, + "path": "/initialSnapshot", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "targetPath", + "expr": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": "/embeddedDocs/hotelOrder", + "else": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "restaurant" + ] + }, + "then": "/embeddedDocs/restaurantOrder", + "else": "" + } + } + } + } + } + }, + { + "$let": { + "name": "expectedKind", + "expr": { + "$choose": { + "cond": { + "$or": [ + { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + { + "$eq": [ + { + "$var": "kind" + }, + "restaurant" + ] + } + ] + }, + "then": "Order", + "else": "" + } + } + } + }, + { + "$let": { + "name": "context", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/context", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "snapshotOrderKind", + "expr": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/orderKind", + "default": "" + } + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/orderKind", + "default": "" + } + } + } + ] + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "targetPath" + } + }, + { + "$ne": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/kind", + "default": "" + } + } + }, + { + "$var": "expectedKind" + } + ] + }, + { + "$ne": [ + { + "$var": "snapshotOrderKind" + }, + { + "$var": "kind" + } + ] + }, + { + "$ne": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/packageOrderDocumentId", + "default": "" + } + } + }, + { + "$text": { + "$document": "/context/packageOrderDocumentId" + } + } + ] + } + ] + }, + "then": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + { + "type": "Coordination/Event", + "kind": "Component Order Attachment Rejected", + "orderKind": { + "$var": "kind" + } + } + ] + } + } + ] + } + }, + { + "$let": { + "name": "existing", + "expr": { + "$object": { + "$document": { + "path": { + "$var": "targetPath" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$gt": [ + { + "$size": { + "$keys": { + "$var": "existing" + } + } + }, + 0 + ] + }, + "then": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + { + "type": "Coordination/Event", + "kind": "Component Order Attachment Rejected", + "orderKind": { + "$var": "kind" + }, + "reason": "component_order_already_attached" + } + ] + } + } + ] + } + }, + { + "$return": { + "changeset": [ + { + "op": "add", + "path": { + "$var": "targetPath" + }, + "val": { + "$var": "snapshot" + } + } + ], + "events": [ + { + "type": "Coordination/Event", + "kind": "Component Order Attached", + "orderKind": { + "$var": "kind" + } + } + ] + } + } + ] + } + ] + } + } + }, + "channelBindings": { + "payerChannel": { + "type": "Coordination/Timeline Channel", + "accountId": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/customerAccountId", + "default": "" + } + } + }, + "payeeChannel": { + "type": "Coordination/Timeline Channel", + "accountId": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/investorAccountId", + "default": "" + } + } + }, + "guarantorChannel": { + "type": "Coordination/Timeline Channel", + "accountId": "0" + } + } + } + } + ] + }, + "maybePrepareCheckoutForOrder": { + "args": { + "sessionId": { + "type": "Text" + }, + "snapshot": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "orderDocumentId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/documentId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "customerAccountId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/customerAccountId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "investorAccountId", + "expr": { + "$text": { + "$document": "/contracts/investorChannel/accountId" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$boolean": { + "$resultValue": "/state/grantsReady" + } + } + }, + { + "$not": { + "$boolean": { + "$resultValue": "/state/paymentTokenSubscriptionReady" + } + } + }, + { + "$not": { + "$var": "sessionId" + } + }, + { + "$not": { + "$var": "orderDocumentId" + } + }, + { + "$not": { + "$var": "customerAccountId" + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "checkoutContext", + "expr": { + "$call": { + "function": "buildCheckoutContext", + "args": { + "orderSessionId": { + "$var": "sessionId" + }, + "orderDocumentId": { + "$var": "orderDocumentId" + }, + "customerAccountId": { + "$var": "customerAccountId" + }, + "investorAccountId": { + "$var": "investorAccountId" + } + } + } + } + } + }, + { + "$let": { + "name": "descriptor", + "expr": { + "$call": { + "function": "buildPackagePayNoteDescriptor", + "args": { + "context": { + "$var": "checkoutContext" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/confirmed" + ] + } + } + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "packageConfirmed", + "val": true + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "sessionId" + }, + "operation": "confirmOrder" + } + } + ] + } + }, + { + "$if": { + "cond": { + "$not": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/customerPayment/tokenRequested" + ] + } + } + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "customerPaymentTokenRequested", + "val": true + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/investorPaymentAccountSessionId" + }, + "operation": "preparePaymentTarget", + "request": { + "requestId": { + "$coalesce": [ + { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/customerPayment/tokenRequestId" + ] + } + } + } + }, + { + "$concat": [ + "reseller-weekend-package-customer-token:", + { + "$var": "sessionId" + } + ] + } + ] + }, + "amount": { + "$const": "expectedPackageAmount" + }, + "currency": "USD", + "expectedPaynote": { + "$var": "descriptor" + } + } + } + } + ] + } + }, + { + "$call": { + "function": "maybeAttachCustomerPaymentTokenForOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "orderSnapshot": { + "$coalesce": [ + { + "$var": "snapshot" + }, + { + } + ] + }, + "tokenOverride": "" + } + } + }, + { + "$return": { + } + } + ] + }, + "maybeAttachCustomerPaymentTokenForOrder": { + "args": { + "sessionId": { + "type": "Text" + }, + "orderSnapshot": { + }, + "tokenOverride": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "token", + "expr": { + "$text": { + "$var": "tokenOverride" + } + } + } + }, + { + "$let": { + "name": "orderDocumentId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/documentId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "customerAccountId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/customerAccountId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "investorAccountId", + "expr": { + "$text": { + "$document": "/contracts/investorChannel/accountId" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "sessionId" + } + }, + { + "$not": { + "$var": "token" + } + }, + { + "$not": { + "$var": "orderDocumentId" + } + }, + { + "$not": { + "$var": "customerAccountId" + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "payment", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$object": { + "$var": "orderSnapshot" + } + }, + "path": "/payment", + "default": { + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$boolean": { + "$pointerGet": { + "object": { + "$var": "payment" + }, + "path": "/tokenAttached", + "default": false + } + } + }, + { + "$eq": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "payment" + }, + "path": "/paymentToken", + "default": "" + } + } + }, + { + "$var": "token" + } + ] + } + ] + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "customerPaymentTokenAttached", + "val": true + } + } + }, + { + "$return": { + } + } + ] + } + }, + { + "$if": { + "cond": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/customerPayment/tokenAttached" + ] + } + } + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "observedStatus", + "expr": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$object": { + "$var": "orderSnapshot" + } + }, + "path": "/status", + "default": "" + } + } + }, + { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/status" + ] + } + } + } + } + ] + } + } + }, + { + "$let": { + "name": "attachable", + "expr": { + "$or": [ + { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/confirmed" + ] + } + } + } + }, + { + "$eq": [ + { + "$var": "observedStatus" + }, + "provider_confirmed_pending_payment_token" + ] + }, + { + "$eq": [ + { + "$var": "observedStatus" + }, + "provider_confirmed" + ] + } + ] + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "attachable" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "customerPaymentTokenAttached", + "val": true + } + } + }, + { + "$let": { + "name": "descriptor", + "expr": { + "$call": { + "function": "buildPackagePayNoteDescriptor", + "args": { + "context": { + "$call": { + "function": "buildCheckoutContext", + "args": { + "orderSessionId": { + "$var": "sessionId" + }, + "orderDocumentId": { + "$var": "orderDocumentId" + }, + "customerAccountId": { + "$var": "customerAccountId" + }, + "investorAccountId": { + "$var": "investorAccountId" + } + } + } + } + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "sessionId" + }, + "operation": "attachPaymentToken", + "request": { + "paymentToken": { + "$var": "token" + }, + "expectedPayNoteDescriptor": { + "$var": "descriptor" + }, + "checkoutMetadata": { + "amountMinor": { + "$const": "expectedPackageAmount" + }, + "currency": "USD", + "packageOrderDocumentId": { + "$var": "orderDocumentId" + } + } + } + } + }, + { + "$return": { + } + } + ] + }, + "recordCustomerPaymentToken": { + "args": { + "requestId": { + }, + "token": { + } + }, + "do": [ + { + "$let": { + "name": "token", + "expr": { + "$text": { + "$var": "token" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "token" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "requestId", + "expr": { + "$text": { + "$var": "requestId" + } + } + } + }, + { + "$let": { + "name": "prefix", + "expr": "reseller-weekend-package-customer-token:" + } + }, + { + "$if": { + "cond": { + "$startsWith": [ + { + "$var": "requestId" + }, + { + "$var": "prefix" + } + ] + }, + "then": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$sliceAfter": [ + { + "$var": "requestId" + }, + { + "$var": "prefix" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "sessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "maybeAttachCustomerPaymentTokenForOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "orderSnapshot": { + }, + "tokenOverride": { + "$var": "token" + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "markPackageOrderObserved": { + "args": { + "sessionId": { + "type": "Text" + }, + "snapshot": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "snapshot" + } + } + } + }, + { + "$let": { + "name": "documentId", + "expr": { + "$call": { + "function": "initializedDocumentId", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + } + } + }, + { + "$let": { + "name": "contracts", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "customerAccountId", + "expr": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "contracts" + }, + "path": "/customerChannel", + "default": { + } + } + }, + "path": "/accountId", + "default": "" + } + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/customerAccountId", + "default": "" + } + } + } + ] + } + } + }, + { + "$let": { + "name": "status", + "expr": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/status", + "default": "" + } + } + }, + "order_created" + ] + } + } + }, + { + "$call": { + "function": "ensureOrderLedger", + "args": { + "sessionId": { + "$var": "sessionId" + } + } + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "packageOrderDocumentId", + "val": { + "$var": "documentId" + } + } + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "customerAccountId", + "val": { + "$var": "customerAccountId" + } + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "relativePath": "/packageOrder/observed", + "val": true + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "relativePath": "/packageOrder/subscriptionId", + "val": { + "$concat": [ + { + "$const": "packageLinkedSubscriptionPrefix" + }, + { + "$var": "sessionId" + } + ] + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "documentId" + } + } + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": { + "$concat": [ + "/packageOrderSessionByDocumentId/", + { + "$var": "documentId" + } + ] + }, + "val": { + "$var": "sessionId" + } + } + } + } + ] + } + }, + { + "$let": { + "name": "payment", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/payment", + "default": { + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$boolean": { + "$pointerGet": { + "object": { + "$var": "payment" + }, + "path": "/tokenAttached", + "default": false + } + } + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "customerPaymentTokenAttached", + "val": true + } + } + } + ] + } + }, + { + "$if": { + "cond": { + "$eq": [ + { + "$var": "status" + }, + "ready_to_use" + ] + }, + "then": [ + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": "/status", + "val": "completed" + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "processSnapshot": { + "args": { + "sessionId": { + "type": "Text" + }, + "snapshot": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "snapshot" + } + } + } + }, + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/kind", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "Package Order" + ] + }, + "then": [ + { + "$call": { + "function": "markPackageOrderObserved", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "snapshot": { + "$var": "snapshot" + } + } + } + }, + { + "$call": { + "function": "maybePrepareCheckoutForOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "snapshot": { + "$var": "snapshot" + } + } + } + }, + { + "$call": { + "function": "maybeAttachCustomerPaymentTokenForOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "orderSnapshot": { + "$var": "snapshot" + }, + "tokenOverride": "" + } + } + }, + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "snapshotContext", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/context", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "orderKind", + "expr": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/orderKind", + "default": "" + } + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshotContext" + }, + "path": "/orderKind", + "default": "" + } + } + } + ] + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$eq": [ + { + "$var": "kind" + }, + "Order" + ] + }, + { + "$or": [ + { + "$eq": [ + { + "$var": "orderKind" + }, + "hotel" + ] + }, + { + "$eq": [ + { + "$var": "orderKind" + }, + "restaurant" + ] + } + ] + } + ] + }, + "then": [ + { + "$let": { + "name": "confirmation", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/confirmation", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$coalesce": [ + { + "$var": "sessionId" + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "snapshotContext" + }, + "path": "/orderSessionId", + "default": "" + } + } + } + ] + } + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/componentOrderRefsBySessionId/", + { + "$var": "componentSessionId" + }, + "/packageOrderSessionId" + ] + } + } + } + } + } + }, + { + "$call": { + "function": "attachComponentSnapshotForOrder", + "args": { + "kind": { + "$var": "orderKind" + }, + "snapshot": { + "$var": "snapshot" + }, + "sourceSessionId": { + "$var": "componentSessionId" + } + } + } + }, + { + "$let": { + "name": "payment", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/payment", + "default": { + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$boolean": { + "$pointerGet": { + "object": { + "$var": "payment" + }, + "path": "/tokenAttached", + "default": false + } + } + }, + "then": [ + { + "$call": { + "function": "maybePayMerchantForToken", + "args": { + "kind": { + "$var": "orderKind" + }, + "componentSessionId": { + "$var": "componentSessionId" + }, + "token": { + "$text": { + "$pointerGet": { + "object": { + "$var": "payment" + }, + "path": "/paymentToken", + "default": "" + } + } + }, + "orderSnapshot": { + "$var": "snapshot" + } + } + } + } + ] + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + { + "$eq": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "confirmation" + }, + "path": "/status", + "default": "" + } + } + }, + "confirmed" + ] + } + ] + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderConfirmed", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "orderKind" + }, + "val": true + } + } + } + } + } + ] + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "recordPlacedResaleOrder": { + "args": { + "agreementKind": { + "type": "Text" + }, + "responseRequestId": { + "type": "Text" + }, + "orderSessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "agreementKind", + "expr": { + "$text": { + "$var": "agreementKind" + } + } + } + }, + { + "$let": { + "name": "responseRequestId", + "expr": { + "$text": { + "$var": "responseRequestId" + } + } + } + }, + { + "$let": { + "name": "orderSessionId", + "expr": { + "$text": { + "$var": "orderSessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "agreementKind" + } + }, + { + "$not": { + "$var": "responseRequestId" + } + }, + { + "$not": { + "$var": "orderSessionId" + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "existingRequest", + "expr": { + "$object": { + "$resultValue": { + "path": { + "$concat": [ + "/resaleOrderRequests/", + { + "$var": "responseRequestId" + } + ] + } + } + } + } + } + }, + { + "$let": { + "name": "nextRequest1", + "expr": { + "$merge": [ + { + "$var": "existingRequest" + }, + { + "kind": { + "$coalesce": [ + { + "$text": { + "$pointerGet": { + "object": { + "$var": "existingRequest" + }, + "path": "/kind", + "default": "" + } + } + }, + { + "$var": "agreementKind" + } + ] + }, + "orderSessionId": { + "$var": "orderSessionId" + }, + "status": "placed" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$ne": [ + { + "$var": "existingRequest" + }, + { + "$var": "nextRequest1" + } + ] + }, + "then": [ + { + "$appendChange": { + "op": "add", + "path": { + "$concat": [ + "/resaleOrderRequests/", + { + "$var": "responseRequestId" + } + ] + }, + "val": { + "$var": "nextRequest1" + } + } + } + ] + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "nextRequest1" + }, + "path": "/packageOrderSessionId", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packageOrderSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "resaleOrderPlaced", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "agreementKind" + }, + "val": true + } + } + } + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderSessions", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "agreementKind" + }, + "val": { + "$var": "orderSessionId" + } + } + } + } + } + }, + { + "$call": { + "function": "appendChangeIfChanged", + "args": { + "path": { + "$concat": [ + "/componentOrderRefsBySessionId/", + { + "$var": "orderSessionId" + } + ] + }, + "val": { + "packageOrderSessionId": { + "$var": "packageOrderSessionId" + }, + "component": { + "$concat": [ + { + "$var": "agreementKind" + }, + "Order" + ] + } + } + } + } + }, + { + "$call": { + "function": "requestComponentOrderDelivery", + "args": { + "packageOrderSessionId": { + "$var": "packageOrderSessionId" + }, + "agreementKind": { + "$var": "agreementKind" + }, + "orderSessionId": { + "$var": "orderSessionId" + } + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "requestComponentOrderDelivery": { + "args": { + "packageOrderSessionId": { + "type": "Text" + }, + "agreementKind": { + "type": "Text" + }, + "orderSessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$var": "packageOrderSessionId" + } + } + } + }, + { + "$let": { + "name": "agreementKind", + "expr": { + "$text": { + "$var": "agreementKind" + } + } + } + }, + { + "$let": { + "name": "orderSessionId", + "expr": { + "$text": { + "$var": "orderSessionId" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "packageOrderSessionId" + } + }, + { + "$not": { + "$var": "agreementKind" + } + }, + { + "$not": { + "$var": "orderSessionId" + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "snapshotRequestId", + "expr": { + "$concat": [ + "snapshot:component:", + { + "$var": "agreementKind" + }, + ":", + { + "$var": "orderSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "subscriptionId", + "expr": { + "$concat": [ + { + "$const": "agreementLinkedSubscriptionPrefix" + }, + { + "$var": "agreementKind" + }, + ":", + { + "$var": "orderSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "componentPathPrefix", + "expr": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "agreementKind" + }, + "hotel" + ] + }, + "then": "/hotelOrder", + "else": "/restaurantOrder" + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$var": "componentPathPrefix" + }, + "/snapshotRequestId" + ] + } + } + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentSnapshotRequestIds", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "agreementKind" + }, + "val": { + "$var": "snapshotRequestId" + } + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Document Initial Snapshot Requested", + "onBehalfOf": "investorChannel", + "requestId": { + "$var": "snapshotRequestId" + }, + "sourceSessionId": { + "$var": "orderSessionId" + } + } + } + ] + } + }, + { + "$if": { + "cond": { + "$not": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$var": "componentPathPrefix" + }, + "/subscriptionId" + ] + } + } + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentSubscriptionIds", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "agreementKind" + }, + "val": { + "$var": "subscriptionId" + } + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Subscribe to Session Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "orderSessionId" + }, + "subscription": { + "id": { + "$var": "subscriptionId" + }, + "events": [ + { + "type": "Coordination/Event", + "kind": "Payment Token Attached" + }, + { + "type": "Coordination/Event", + "kind": "Order Confirmed" + } + ] + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "placeResaleOrdersForOrder": { + "args": { + "sessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "orderDocumentId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/documentId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "customerAccountId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/customerAccountId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "sessionId" + } + }, + { + "$not": { + "$var": "orderDocumentId" + } + }, + { + "$not": { + "$var": "customerAccountId" + } + }, + { + "$not": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/customerPayNote/secured" + ] + } + } + } + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "placeOneResaleOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "kind": "hotel", + "agreementSessionId": { + "$document": "/hotelAgreementSessionId" + }, + "ready": { + "$boolean": { + "$resultValue": "/state/hotelAgreementSubscriptionReady" + } + }, + "entitlement": { + "title": "Weekend room", + "description": "Two-night weekend stay." + } + } + } + }, + { + "$call": { + "function": "placeOneResaleOrder", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "kind": "restaurant", + "agreementSessionId": { + "$document": "/restaurantAgreementSessionId" + }, + "ready": { + "$boolean": { + "$resultValue": "/state/restaurantAgreementSubscriptionReady" + } + }, + "entitlement": { + "title": "Two-dish dinner with selected wines", + "description": "Dinner menu with selected wines." + } + } + } + }, + { + "$return": { + } + } + ] + }, + "placeOneResaleOrder": { + "args": { + "sessionId": { + "type": "Text" + }, + "kind": { + "type": "Text" + }, + "agreementSessionId": { + "type": "Text" + }, + "ready": { + "type": "Boolean" + }, + "entitlement": { + } + }, + "do": [ + { + "$let": { + "name": "sessionId", + "expr": { + "$text": { + "$var": "sessionId" + } + } + } + }, + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$var": "kind" + } + } + } + }, + { + "$let": { + "name": "agreementSessionId", + "expr": { + "$text": { + "$var": "agreementSessionId" + } + } + } + }, + { + "$let": { + "name": "ready", + "expr": { + "$boolean": { + "$var": "ready" + } + } + } + }, + { + "$let": { + "name": "requestId", + "expr": { + "$concat": [ + "resale:", + { + "$var": "sessionId" + }, + ":", + { + "$var": "kind" + } + ] + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "agreementSessionId" + } + }, + { + "$not": { + "$var": "ready" + } + }, + { + "$not": { + "$not": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/resaleOrderRequests/", + { + "$var": "requestId" + }, + "/kind" + ] + } + } + } + } + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$appendChange": { + "op": "add", + "path": { + "$concat": [ + "/resaleOrderRequests/", + { + "$var": "requestId" + } + ] + }, + "val": { + "status": "requested", + "agreementSessionId": { + "$var": "agreementSessionId" + }, + "kind": { + "$var": "kind" + }, + "packageOrderSessionId": { + "$var": "sessionId" + }, + "orderSessionId": "" + } + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "resaleOrderRequested", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": true + } + } + } + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "sessionId" + }, + "key": "resaleOrderRequestIds", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": { + "$var": "requestId" + } + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "agreementSessionId" + }, + "operation": "placeResaleOrder", + "request": { + "requestId": { + "$var": "requestId" + }, + "customerLabel": "Customer A", + "customerAccountId": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/customerAccountId" + ] + } + } + } + }, + "packageOrderDocumentId": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "sessionId" + }, + "/packageOrder/documentId" + ] + } + } + } + }, + "orderKind": { + "$var": "kind" + }, + "entitlement": { + "$var": "entitlement" + } + } + } + }, + { + "$return": { + } + } + ] + }, + "markPackagePayNoteSecured": { + "args": { + "packageOrderSessionId": { + "type": "Text" + }, + "amountSecured": { + } + }, + "do": [ + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$var": "packageOrderSessionId" + } + } + } + }, + { + "$let": { + "name": "normalizedAmount", + "expr": { + "$integer": { + "$coalesce": [ + { + "$var": "amountSecured" + }, + 0 + ] + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "packageOrderSessionId" + } + }, + { + "$ne": [ + { + "$var": "normalizedAmount" + }, + { + "$const": "expectedPackageAmount" + } + ] + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "packagePayNoteSecured", + "val": true + } + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "packagePayNoteSecuredAmount", + "val": { + "$var": "normalizedAmount" + } + } + } + }, + { + "$call": { + "function": "placeResaleOrdersForOrder", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + } + } + } + }, + { + "$return": { + } + } + ] + }, + "markPackagePayNoteSecuredFromSnapshot": { + "args": { + "snapshot": { + } + }, + "do": [ + { + "$if": { + "cond": { + "$not": { + "$call": { + "function": "isCustomerPackagePayNoteSnapshot", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "context", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$object": { + "$var": "snapshot" + } + }, + "path": "/context", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "packageOrderDocumentId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/packageOrderDocumentId", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/packageOrderSessionByDocumentId/", + { + "$var": "packageOrderDocumentId" + } + ] + } + } + } + } + } + }, + { + "$let": { + "name": "amount", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$object": { + "$var": "snapshot" + } + }, + "path": "/amount", + "default": { + } + } + } + } + } + }, + { + "$call": { + "function": "markPackagePayNoteSecured", + "args": { + "packageOrderSessionId": { + "$var": "packageOrderSessionId" + }, + "amountSecured": { + "$integer": { + "$pointerGet": { + "object": { + "$var": "amount" + }, + "path": "/secured", + "default": 0 + } + } + } + } + } + }, + { + "$return": { + } + } + ] + }, + "processCustomerPayNoteInitialSnapshot": { + "args": { + "payNoteSessionId": { + "type": "Text" + }, + "snapshot": { + } + }, + "do": [ + { + "$let": { + "name": "payNoteSessionId", + "expr": { + "$text": { + "$var": "payNoteSessionId" + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "snapshot" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "payNoteSessionId" + } + }, + { + "$not": { + "$call": { + "function": "isCustomerPackagePayNoteSnapshot", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "context", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/context", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "packageOrderDocumentId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/packageOrderDocumentId", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/packageOrderSessionByDocumentId/", + { + "$var": "packageOrderDocumentId" + } + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "packageOrderSessionId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "ensureOrderLedger", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + } + } + } + }, + { + "$let": { + "name": "snapshotRequestId", + "expr": { + "$concat": [ + { + "$const": "customerPayNoteSnapshotPrefix" + }, + { + "$var": "payNoteSessionId" + } + ] + } + } + }, + { + "$let": { + "name": "subscriptionId", + "expr": { + "$concat": [ + { + "$const": "packageLinkedSubscriptionPrefix" + }, + { + "$var": "payNoteSessionId" + } + ] + } + } + }, + { + "$appendChange": { + "op": "add", + "path": { + "$concat": [ + "/customerPayNoteRefsBySessionId/", + { + "$var": "payNoteSessionId" + } + ] + }, + "val": { + "sessionId": { + "$var": "payNoteSessionId" + }, + "packageOrderSessionId": { + "$var": "packageOrderSessionId" + }, + "packageOrderDocumentId": { + "$var": "packageOrderDocumentId" + }, + "snapshotRequestId": { + "$var": "snapshotRequestId" + }, + "subscriptionId": { + "$var": "subscriptionId" + } + } + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "packagePayNoteSessionId", + "val": { + "$var": "payNoteSessionId" + } + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "relativePath": "/customerPayNote/snapshotRequestId", + "val": { + "$var": "snapshotRequestId" + } + } + } + }, + { + "$call": { + "function": "setOrderPath", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "relativePath": "/customerPayNote/subscriptionId", + "val": { + "$var": "subscriptionId" + } + } + } + }, + { + "$call": { + "function": "markPackagePayNoteSecuredFromSnapshot", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + }, + { + "$if": { + "cond": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + "/customerPayNote/attachedToPackageOrder" + ] + } + } + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "packagePayNoteAttached", + "val": true + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "packageOrderSessionId" + }, + "operation": "attachPayNote", + "request": { + "payNoteSessionId": { + "$var": "payNoteSessionId" + }, + "initialSnapshot": { + "$var": "snapshot" + } + } + } + }, + { + "$return": { + } + } + ] + }, + "buildMerchantPayNoteDescriptor": { + "args": { + "kind": { + "type": "Text" + }, + "amountMinor": { + "type": "Integer" + }, + "orderSnapshot": { + }, + "orderSessionId": { + "type": "Text" + } + }, + "do": [ + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$var": "kind" + } + } + } + }, + { + "$let": { + "name": "amountMinor", + "expr": { + "$integer": { + "$var": "amountMinor" + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "orderSnapshot" + } + } + } + }, + { + "$return": { + "document": { + "name": "Boutique Travel Agency Merchant PayNote", + "type": "PayNote/PayNote", + "kind": "PayNote", + "description": "Boutique Travel Agency merchant payout secured before fulfillment.", + "payNoteInitialStateDescription": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": { + "summary": "Secured payout for the Hotel Aurora stay order.", + "details": "This PayNote secures Boutique Travel Agency's payment to Hotel Aurora for the customer's weekend stay order. Funds are secured before the customer checks in. The payment completes when Hotel Aurora confirms check-in on the embedded Hotel Stay Order." + }, + "else": { + "summary": "Secured payout for the Restaurant Lumi dinner order.", + "details": "This PayNote secures Boutique Travel Agency's payment to Restaurant Lumi for the customer's wine dinner order. Funds are secured before the restaurant visit. The payment completes when Restaurant Lumi confirms the visit on the embedded Restaurant Dinner Order." + } + } + }, + "state": "not_started", + "currency": "USD", + "amount": { + "expectedTotal": { + "$var": "amountMinor" + } + }, + "context": { + "paymentPurpose": "merchant_resale_payout", + "orderDocumentId": { + "$call": { + "function": "initializedDocumentId", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + }, + "agreementDocumentId": { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts", + "default": { + } + } + }, + "path": "/links", + "default": { + } + } + }, + "path": "/resaleAgreement/documentId", + "default": "" + } + } + } + }, + "embeddedDocs": { + "order": { + "$var": "snapshot" + } + }, + "completionRequested": false, + "contracts": { + "payerChannel": { + "type": "Coordination/Timeline Channel" + }, + "payeeChannel": { + "type": "Coordination/Timeline Channel" + }, + "guarantorChannel": { + "type": "Coordination/Timeline Channel" + }, + "links": { + "type": "Sample/Document Links", + "resaleAgreement": { + "type": "Sample/Document Link", + "documentId": { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts", + "default": { + } + } + }, + "path": "/links", + "default": { + } + } + }, + "path": "/resaleAgreement/documentId", + "default": "" + } + } + }, + "anchor": "merchantPayNotes" + } + }, + "embedded": { + "type": "Process Embedded", + "paths": [ + "/embeddedDocs/order" + ] + }, + "embeddedOrderEvents": { + "type": "Embedded Node Channel", + "childPath": "/embeddedDocs/order" + }, + "completeOnFulfillmentEvent": { + "type": "Coordination/Sequential Workflow", + "channel": "embeddedOrderEvents", + "event": { + "type": "Coordination/Event", + "kind": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": "Hotel Check-In Confirmed", + "else": "Restaurant Visit Confirmed" + } + } + }, + "steps": [ + { + "name": "BuildEventCompletion", + "type": "Coordination/Compute", + "emitEvents": true, + "returnResult": true, + "do": [ + { + "$if": { + "cond": { + "$boolean": { + "$document": "/completionRequested" + } + }, + "then": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + + ] + } + } + ] + } + }, + { + "$return": { + "changeset": [ + { + "op": "replace", + "path": "/completionRequested", + "val": true + } + ], + "events": [ + { + "type": "PayNote/Complete Payment Requested", + "amount": { + "$var": "amountMinor" + } + } + ] + } + } + ] + } + ] + } + } + }, + "channelBindings": { + "payerChannel": { + "type": "Coordination/Timeline Channel", + "accountId": { + "$text": { + "$document": "/contracts/investorChannel/accountId" + } + } + }, + "payeeChannel": { + "type": "Coordination/Timeline Channel", + "accountId": { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts/sellerChannel", + "default": { + } + } + }, + "path": "/accountId", + "default": "" + } + } + } + }, + "guarantorChannel": { + "type": "Coordination/Timeline Channel", + "accountId": "0" + } + } + } + } + ] + }, + "maybePayMerchantForToken": { + "args": { + "kind": { + "type": "Text" + }, + "componentSessionId": { + "type": "Text" + }, + "token": { + "type": "Text" + }, + "orderSnapshot": { + } + }, + "do": [ + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$var": "kind" + } + } + } + }, + { + "$let": { + "name": "componentSessionId", + "expr": { + "$text": { + "$var": "componentSessionId" + } + } + } + }, + { + "$let": { + "name": "token", + "expr": { + "$text": { + "$var": "token" + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "kind" + } + }, + { + "$not": { + "$var": "componentSessionId" + } + }, + { + "$not": { + "$var": "token" + } + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$call": { + "function": "findPackageOrderByComponentSession", + "args": { + "kind": { + "$var": "kind" + }, + "componentSessionId": { + "$var": "componentSessionId" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "packageOrderSessionId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$if": { + "cond": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": "/hotelOrder/merchantPaymentInitiated", + "else": "/restaurantOrder/merchantPaymentInitiated" + } + } + ] + } + } + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "orderSnapshot" + } + } + } + }, + { + "$let": { + "name": "orderDocumentId", + "expr": { + "$call": { + "function": "initializedDocumentId", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + } + } + }, + { + "$let": { + "name": "contracts", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/contracts", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "sellerAccountId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "contracts" + }, + "path": "/sellerChannel", + "default": { + } + } + }, + "path": "/accountId", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "agreementDocumentId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$pointerGet": { + "object": { + "$var": "contracts" + }, + "path": "/links", + "default": { + } + } + }, + "path": "/resaleAgreement", + "default": { + } + } + }, + "path": "/documentId", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$not": { + "$var": "orderDocumentId" + } + }, + { + "$not": { + "$var": "sellerAccountId" + } + }, + { + "$not": { + "$var": "agreementDocumentId" + } + } + ] + }, + "then": [ + { + "$appendEvent": { + "type": "Sample/Document Initial Snapshot Requested", + "onBehalfOf": "investorChannel", + "requestId": { + "$concat": [ + "snapshot:component:", + { + "$var": "kind" + }, + ":", + { + "$var": "componentSessionId" + } + ] + }, + "sourceSessionId": { + "$var": "componentSessionId" + } + } + }, + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "amountMinor", + "expr": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": { + "$const": "hotelAmountMinor" + }, + "else": { + "$const": "restaurantAmountMinor" + } + } + } + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "merchantPaymentInitiated", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": true + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/investorPaymentAccountSessionId" + }, + "operation": "pay", + "request": { + "requestId": { + "$concat": [ + "reseller-weekend-package-", + { + "$var": "kind" + }, + "-merchant-payment-", + { + "$document": "/runId" + }, + "-", + { + "$var": "packageOrderSessionId" + } + ] + }, + "recipient": { + "type": "Sample/Sample Balance Account", + "token": { + "$var": "token" + } + }, + "amount": { + "$var": "amountMinor" + }, + "currency": "USD", + "paynote": { + "$call": { + "function": "buildMerchantPayNoteDescriptor", + "args": { + "kind": { + "$var": "kind" + }, + "amountMinor": { + "$var": "amountMinor" + }, + "orderSnapshot": { + "$var": "snapshot" + }, + "orderSessionId": { + "$var": "componentSessionId" + } + } + } + } + } + } + }, + { + "$return": { + } + } + ] + }, + "attachComponentSnapshotForOrder": { + "args": { + "kind": { + "type": "Text" + }, + "snapshot": { + }, + "sourceSessionId": { + } + }, + "do": [ + { + "$let": { + "name": "kind", + "expr": { + "$text": { + "$var": "kind" + } + } + } + }, + { + "$let": { + "name": "snapshot", + "expr": { + "$object": { + "$var": "snapshot" + } + } + } + }, + { + "$let": { + "name": "context", + "expr": { + "$object": { + "$pointerGet": { + "object": { + "$var": "snapshot" + }, + "path": "/context", + "default": { + } + } + } + } + } + }, + { + "$let": { + "name": "nextSessionId", + "expr": { + "$coalesce": [ + { + "$text": { + "$var": "sourceSessionId" + } + }, + { + "$text": { + "$pointerGet": { + "object": { + "$var": "context" + }, + "path": "/orderSessionId", + "default": "" + } + } + } + ] + } + } + }, + { + "$let": { + "name": "ref", + "expr": { + "$object": { + "$resultValue": { + "path": { + "$concat": [ + "/componentOrderRefsBySessionId/", + { + "$var": "nextSessionId" + } + ] + } + } + } + } + } + }, + { + "$let": { + "name": "packageOrderSessionId", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "ref" + }, + "path": "/packageOrderSessionId", + "default": "" + } + } + } + } + }, + { + "$let": { + "name": "component", + "expr": { + "$text": { + "$pointerGet": { + "object": { + "$var": "ref" + }, + "path": "/component", + "default": "" + } + } + } + } + }, + { + "$if": { + "cond": { + "$and": [ + { + "$not": { + "$not": { + "$var": "component" + } + } + }, + { + "$ne": [ + { + "$var": "component" + }, + { + "$concat": [ + { + "$var": "kind" + }, + "Order" + ] + } + ] + } + ] + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "packageOrderSessionId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "ensureOrderLedger", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + } + } + } + }, + { + "$let": { + "name": "prefix", + "expr": { + "$choose": { + "cond": { + "$eq": [ + { + "$var": "kind" + }, + "hotel" + ] + }, + "then": "/hotelOrder", + "else": "/restaurantOrder" + } + } + } + }, + { + "$let": { + "name": "previousSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$var": "prefix" + }, + "/sessionId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "previousDocumentId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$var": "prefix" + }, + "/documentId" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "nextDocumentId", + "expr": { + "$call": { + "function": "initializedDocumentId", + "args": { + "snapshot": { + "$var": "snapshot" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$var": "nextDocumentId" + } + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$let": { + "name": "alreadyAttached", + "expr": { + "$boolean": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + { + "$var": "prefix" + }, + "/attachedToPackageOrder" + ] + } + } + } + } + } + }, + { + "$let": { + "name": "sameRef", + "expr": { + "$and": [ + { + "$var": "alreadyAttached" + }, + { + "$eq": [ + { + "$var": "previousSessionId" + }, + { + "$var": "nextSessionId" + } + ] + }, + { + "$eq": [ + { + "$var": "previousDocumentId" + }, + { + "$var": "nextDocumentId" + } + ] + } + ] + } + } + }, + { + "$if": { + "cond": { + "$var": "sameRef" + }, + "then": [ + { + "$return": { + } + } + ] + } + }, + { + "$if": { + "cond": { + "$or": [ + { + "$and": [ + { + "$not": { + "$not": { + "$var": "previousSessionId" + } + } + }, + { + "$not": { + "$not": { + "$var": "nextSessionId" + } + } + }, + { + "$ne": [ + { + "$var": "previousSessionId" + }, + { + "$var": "nextSessionId" + } + ] + } + ] + }, + { + "$and": [ + { + "$not": { + "$not": { + "$var": "previousDocumentId" + } + } + }, + { + "$ne": [ + { + "$var": "previousDocumentId" + }, + { + "$var": "nextDocumentId" + } + ] + } + ] + } + ] + }, + "then": [ + { + "$call": { + "function": "setOrderField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": { + "$concat": [ + { + "$var": "kind" + }, + "ComponentRejected" + ] + }, + "val": "component_order_already_attached" + } + } + }, + { + "$return": { + } + } + ] + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderAttached", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": true + } + } + } + } + }, + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderDocumentIds", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": { + "$var": "nextDocumentId" + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "nextSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderSessions", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": { + "$var": "nextSessionId" + } + } + } + } + } + } + ] + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "packageOrderSessionId" + }, + "operation": "attachComponentOrder", + "request": { + "kind": { + "$var": "kind" + }, + "initialSnapshot": { + "$var": "snapshot" + } + } + } + }, + { + "$let": { + "name": "packagePayNoteSessionId", + "expr": { + "$text": { + "$resultValue": { + "path": { + "$concat": [ + "/orders/", + { + "$var": "packageOrderSessionId" + }, + "/customerPayNote/sessionId" + ] + } + } + } + } + } + }, + { + "$if": { + "cond": { + "$not": { + "$not": { + "$var": "packagePayNoteSessionId" + } + } + }, + "then": [ + { + "$call": { + "function": "mergeOrderObjectField", + "args": { + "sessionId": { + "$var": "packageOrderSessionId" + }, + "key": "componentOrderAttachedToPayNote", + "patch": { + "$objectSet": { + "object": { + }, + "key": { + "$var": "kind" + }, + "val": true + } + } + } + } + }, + { + "$appendEvent": { + "type": "Sample/Call Operation Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$var": "packagePayNoteSessionId" + }, + "operation": "attachComponentOrder", + "request": { + "kind": { + "$var": "kind" + }, + "initialSnapshot": { + "$var": "snapshot" + } + } + } + } + ] + } + }, + { + "$return": { + } + } + ] + }, + "buildPackageFulfillmentSetupRequests": { + "do": [ + { + "$return": { + "changeset": [ + + ], + "events": [ + { + "type": "Sample/Single Document Permission Grant Requested", + "onBehalfOf": "investorChannel", + "requestId": { + "$concat": [ + "sdpg:package:investor-payment-account:", + { + "$document": "/investorPaymentAccountSessionId" + } + ] + }, + "targetSessionId": { + "$document": "/investorPaymentAccountSessionId" + }, + "permissions": { + "read": true, + "singleOps": [ + "pay", + "preparePaymentTarget" + ] + } + }, + { + "type": "Sample/Single Document Permission Grant Requested", + "onBehalfOf": "investorChannel", + "requestId": { + "$concat": [ + "sdpg:package:hotel-agreement:", + { + "$document": "/hotelAgreementSessionId" + } + ] + }, + "targetSessionId": { + "$document": "/hotelAgreementSessionId" + }, + "permissions": { + "read": true, + "singleOps": [ + "placeResaleOrder" + ] + } + }, + { + "type": "Sample/Single Document Permission Grant Requested", + "onBehalfOf": "investorChannel", + "requestId": { + "$concat": [ + "sdpg:package:restaurant-agreement:", + { + "$document": "/restaurantAgreementSessionId" + } + ] + }, + "targetSessionId": { + "$document": "/restaurantAgreementSessionId" + }, + "permissions": { + "read": true, + "singleOps": [ + "placeResaleOrder" + ] + } + }, + { + "type": "Sample/Linked Documents Permission Grant Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/packageOfferSessionId" + }, + "requestId": { + "$concat": [ + "ldpg:package-offer:orders:", + { + "$document": "/packageOfferSessionId" + } + ] + }, + "name": "Package offer order links", + "links": { + "orders": { + "read": true, + "singleOps": [ + "confirmOrder", + "attachPaymentToken", + "attachPayNote", + "attachComponentOrder" + ] + } + } + }, + { + "type": "Sample/Linked Documents Permission Grant Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/packageOfferSessionId" + }, + "requestId": { + "$concat": [ + "ldpg:package-offer:customer-paynotes:", + { + "$document": "/packageOfferSessionId" + } + ] + }, + "name": "Package offer customer PayNote links", + "links": { + "customerPayNotes": { + "read": true, + "singleOps": [ + "attachComponentOrder" + ] + } + } + }, + { + "type": "Sample/Linked Documents Permission Grant Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/hotelAgreementSessionId" + }, + "requestId": { + "$concat": [ + "ldpg:hotel-agreement:orders:", + { + "$document": "/hotelAgreementSessionId" + } + ] + }, + "name": "Hotel agreement order links", + "links": { + "orders": { + "read": true + } + } + }, + { + "type": "Sample/Linked Documents Permission Grant Requested", + "onBehalfOf": "investorChannel", + "targetSessionId": { + "$document": "/restaurantAgreementSessionId" + }, + "requestId": { + "$concat": [ + "ldpg:restaurant-agreement:orders:", + { + "$document": "/restaurantAgreementSessionId" + } + ] + }, + "name": "Restaurant agreement order links", + "links": { + "orders": { + "read": true + } + } + } + ] + } + } + ] + } + } + } + }, + "kind": "Global Package Fulfillment Automation", + "status": "active", + "hotelAgreementSessionId": "hotel-agreement-session", + "investorPaymentAccountSessionId": "investor-payment-session", + "packageOfferDocumentId": "783DnFBHNTYAntUMGupaoArsByZ4f2Aet55aJ6UR6bHg", + "packageOfferSessionId": "package-offer-session", + "restaurantAgreementSessionId": "restaurant-agreement-session", + "runId": "harness", + "packageOrderSessionByDocumentId": { + "type": "Dictionary", + "keyType": "Text", + "valueType": "Text", + "zzimVxhnKLL5SwMkS9kmF8p5g7pyxPWBu664HxGbszB": "package-order-a", + "Dkik7zyrq8AZqGXCimyioGQAdYKz2SuGMpkVV1ZrgmXS": "package-order-b" + }, + "customerPayNoteRefsBySessionId": { + "type": "Dictionary", + "keyType": "Text", + "valueType": { + "sessionId": { + "type": "Text" + }, + "packageOrderSessionId": { + "type": "Text" + }, + "packageOrderDocumentId": { + "type": "Text" + }, + "snapshotRequestId": { + "type": "Text" + }, + "subscriptionId": { + "type": "Text" + } + }, + "customer-paynote-a": { + "sessionId": "customer-paynote-a", + "subscriptionId": "package-linked:customer-paynote-a", + "snapshotRequestId": "snapshot:customer-paynote:customer-paynote-a", + "packageOrderSessionId": "", + "packageOrderDocumentId": "" + } + }, + "componentOrderRefsBySessionId": { + "type": "Dictionary", + "keyType": "Text", + "valueType": { + "packageOrderSessionId": { + "type": "Text" + }, + "component": { + "type": "Text" + } + } + }, + "orders": { + "type": "Dictionary", + "keyType": "Text", + "valueType": { + "packageOrder": { + "sessionId": { + "type": "Text" + }, + "documentId": { + "type": "Text" + }, + "customerAccountId": { + "type": "Text" + }, + "subscriptionId": { + "type": "Text" + }, + "observed": { + "type": "Boolean" + }, + "confirmed": { + "type": "Boolean" + } + }, + "customerPayment": { + "tokenRequestId": { + "type": "Text" + }, + "tokenRequested": { + "type": "Boolean" + }, + "tokenAttached": { + "type": "Boolean" + } + }, + "customerPayNote": { + "sessionId": { + "type": "Text" + }, + "snapshotRequestId": { + "type": "Text" + }, + "subscriptionId": { + "type": "Text" + }, + "attachedToPackageOrder": { + "type": "Boolean" + }, + "secured": { + "type": "Boolean" + }, + "securedAmount": { + "type": "Integer" + }, + "completed": { + "type": "Boolean" + } + }, + "hotelOrder": { + "sessionId": { + "type": "Text" + }, + "documentId": { + "type": "Text" + }, + "resaleRequestId": { + "type": "Text" + }, + "resaleRequested": { + "type": "Boolean" + }, + "resalePlaced": { + "type": "Boolean" + }, + "snapshotRequestId": { + "type": "Text" + }, + "subscriptionId": { + "type": "Text" + }, + "attachedToPackageOrder": { + "type": "Boolean" + }, + "attachedToPayNote": { + "type": "Boolean" + }, + "merchantPaymentInitiated": { + "type": "Boolean" + }, + "confirmed": { + "type": "Boolean" + } + }, + "restaurantOrder": { + "sessionId": { + "type": "Text" + }, + "documentId": { + "type": "Text" + }, + "resaleRequestId": { + "type": "Text" + }, + "resaleRequested": { + "type": "Boolean" + }, + "resalePlaced": { + "type": "Boolean" + }, + "snapshotRequestId": { + "type": "Text" + }, + "subscriptionId": { + "type": "Text" + }, + "attachedToPackageOrder": { + "type": "Boolean" + }, + "attachedToPayNote": { + "type": "Boolean" + }, + "merchantPaymentInitiated": { + "type": "Boolean" + }, + "confirmed": { + "type": "Boolean" + } + } + }, + "package-order-a": { + "hotelOrder": { + "confirmed": false, + "sessionId": "", + "documentId": "", + "resalePlaced": false, + "subscriptionId": "", + "resaleRequestId": "", + "resaleRequested": false, + "attachedToPayNote": false, + "snapshotRequestId": "", + "attachedToPackageOrder": false, + "merchantPaymentInitiated": false + }, + "packageOrder": { + "observed": true, + "confirmed": true, + "sessionId": "package-order-a", + "documentId": "zzimVxhnKLL5SwMkS9kmF8p5g7pyxPWBu664HxGbszB", + "subscriptionId": "package-linked:package-order-a", + "customerAccountId": "customer-a-uid" + }, + "customerPayNote": { + "secured": false, + "completed": false, + "sessionId": "", + "securedAmount": 0, + "subscriptionId": "", + "snapshotRequestId": "", + "attachedToPackageOrder": false + }, + "customerPayment": { + "tokenAttached": true, + "tokenRequestId": "reseller-weekend-package-customer-token:package-order-a", + "tokenRequested": true + }, + "restaurantOrder": { + "confirmed": false, + "sessionId": "", + "documentId": "", + "resalePlaced": false, + "subscriptionId": "", + "resaleRequestId": "", + "resaleRequested": false, + "attachedToPayNote": false, + "snapshotRequestId": "", + "attachedToPackageOrder": false, + "merchantPaymentInitiated": false + } + }, + "package-order-b": { + "hotelOrder": { + "confirmed": false, + "sessionId": "", + "documentId": "", + "resalePlaced": false, + "subscriptionId": "", + "resaleRequestId": "", + "resaleRequested": false, + "attachedToPayNote": false, + "snapshotRequestId": "", + "attachedToPackageOrder": false, + "merchantPaymentInitiated": false + }, + "packageOrder": { + "observed": true, + "confirmed": true, + "sessionId": "package-order-b", + "documentId": "Dkik7zyrq8AZqGXCimyioGQAdYKz2SuGMpkVV1ZrgmXS", + "subscriptionId": "package-linked:package-order-b", + "customerAccountId": "customer-b-uid" + }, + "customerPayNote": { + "secured": false, + "completed": false, + "sessionId": "", + "securedAmount": 0, + "subscriptionId": "", + "snapshotRequestId": "", + "attachedToPackageOrder": false + }, + "customerPayment": { + "tokenAttached": true, + "tokenRequestId": "reseller-weekend-package-customer-token:package-order-b", + "tokenRequested": true + }, + "restaurantOrder": { + "confirmed": false, + "sessionId": "", + "documentId": "", + "resalePlaced": false, + "subscriptionId": "", + "resaleRequestId": "", + "resaleRequested": false, + "attachedToPayNote": false, + "snapshotRequestId": "", + "attachedToPackageOrder": false, + "merchantPaymentInitiated": false + } + } + }, + "resaleOrderRequests": { + "type": "Dictionary", + "keyType": "Text", + "valueType": { + "status": { + "type": "Text" + }, + "agreementSessionId": { + "type": "Text" + }, + "kind": { + "type": "Text" + }, + "packageOrderSessionId": { + "type": "Text" + }, + "orderSessionId": { + "type": "Text" + } + } + }, + "counters": { + "resaleOrderRequestSeq": 0 + }, + "state": { + "agreementSubscriptionsRequested": true, + "grantsReady": true, + "hotelAgreementSubscriptionReady": true, + "hotelOrdersLdpgReady": true, + "packageOfferLdpgReady": true, + "paymentTokenSubscriptionReady": true, + "paymentTokenSubscriptionRequested": true, + "restaurantAgreementSubscriptionReady": true, + "restaurantOrdersLdpgReady": true, + "customerPayNotesLdpgReady": true, + "setupGrants": { + "hotelAgreement": true, + "investorPaymentAccount": true, + "restaurantAgreement": true + } + } +} diff --git a/src/test/resources/processor-delay/customer-paynote-snapshot.event.yaml b/src/test/resources/processor-delay/customer-paynote-snapshot.event.yaml new file mode 100644 index 0000000..5d89da1 --- /dev/null +++ b/src/test/resources/processor-delay/customer-paynote-snapshot.event.yaml @@ -0,0 +1,267 @@ +# Generated from src/test/resources/processor-delay/customer-paynote-snapshot.event.json +type: "Coordination/Timeline Entry" +timeline: + timelineId: "admin-timeline" +timestamp: 1700000000000 +actor: + type: "Sample/Principal Actor" + accountId: "0" +message: + type: "Coordination/Operation Request" + operation: "sampleAdminUpdate" + request: + - type: "Sample/Document Initial Snapshot Resolved" + inResponseTo: + requestId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "snapshot:customer-paynote:customer-paynote-a" + document: + name: "Customer to Boutique Travel Agency Package PayNote" + description: "Customer package payment secured before provider orders." + type: { blueId: "emSg8pWstEHBtnbUPNu7rmqMzWskDCUbyggteUdk32w" } + amount: + expectedTotal: + type: { blueId: "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1" } + value: 100000 + secured: + type: { blueId: "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1" } + value: 100000 + contracts: + guarantorChannel: + type: { blueId: "HCF8mXnX3dFjQ8osjxb4Wzm2Nm1DoXnTYuA5sPnV7NTs" } + timelineId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "admin-timeline" + accountId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "0" + payeeChannel: + type: { blueId: "HCF8mXnX3dFjQ8osjxb4Wzm2Nm1DoXnTYuA5sPnV7NTs" } + timelineId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "investor-timeline" + accountId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "investor-uid" + payerChannel: + type: { blueId: "HCF8mXnX3dFjQ8osjxb4Wzm2Nm1DoXnTYuA5sPnV7NTs" } + timelineId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "a-customer-timeline" + accountId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customer-a-uid" + links: + type: { blueId: "4cmrbevB6K23ZenjqwmNxpnaw6RF4VB3wkP7XB59V7W5" } + packageOffer: + type: { blueId: "BFxgEnovNHQ693YR2YvALi4FP8vjcwSQiX63LiLwjUhk" } + anchor: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customerPayNotes" + documentId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "783DnFBHNTYAntUMGupaoArsByZ4f2Aet55aJ6UR6bHg" + packageOrder: + type: { blueId: "BFxgEnovNHQ693YR2YvALi4FP8vjcwSQiX63LiLwjUhk" } + anchor: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "payments" + documentId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "zzimVxhnKLL5SwMkS9kmF8p5g7pyxPWBu664HxGbszB" + attachComponentOrder: + description: "Attaches an included merchant order snapshot so package payment can complete after both confirmations." + type: { blueId: "BoAiqVUZv9Fum3wFqaX2JnQMBHJLxJSo2V9U2UBmCfsC" } + channel: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "payeeChannel" + request: + kind: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + initialSnapshot: + type: { blueId: "J18rFf6VX3ADe5gTnqmL4wXtivLkzrRXLPPhnoghnjzB" } + attachComponentOrderImpl: + type: { blueId: "CGdxkNjPcsdescqLPz6SNLsMyak6demQQr7RoKNHbCyv" } + steps: + items: + - name: "BuildComponentAttachment" + type: { blueId: "ExZxT61PSpWHpEAtP2WKMXXqxEYN7Z13j7Zv36Dp99kS" } + code: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "const unwrap = value => value && typeof value === 'object' && value.value \u0021== undefined ? value.value : value; const readField = (object, key) => unwrap((object || {})[key]); const req = event.message.request || {}; const kind = unwrap(req.kind) || ''; const snapshot = req.initialSnapshot || {}; const expectedKind = kind === 'hotel' || kind === 'restaurant' ? 'Order' : ''; const targetPath = kind === 'hotel' ? '/embeddedDocs/hotelOrder' : kind === 'restaurant' ? '/embeddedDocs/restaurantOrder' : ''; const context = snapshot.context || {}; const existing = targetPath ? document(targetPath) || {} : {}; const snapshotOrderKind = readField(snapshot, 'orderKind') || readField(context, 'orderKind'); if (\u0021targetPath || readField(snapshot, 'kind') \u0021== expectedKind || snapshotOrderKind \u0021== kind || readField(context, 'packageOrderDocumentId') \u0021== document('/context/packageOrderDocumentId')) return { changeset: [], events: [{ type: 'Coordination/Event', kind: 'Component Order Attachment Rejected', orderKind: kind }] }; if (existing && Object.keys(existing).length > 0) return { changeset: [], events: [{ type: 'Coordination/Event', kind: 'Component Order Attachment Rejected', orderKind: kind, reason: 'component_order_already_attached' }] }; return { changeset: [{ op: 'add', path: targetPath, val: snapshot }], events: [{ type: 'Coordination/Event', kind: 'Component Order Attached', orderKind: kind }] };" + - name: "ApplyComponentAttachment" + type: { blueId: "FtHZJzH4hqAoGxFBjsmy1svfT4BwEBB4aHpFSZycZLLa" } + changeset: + $binding: + name: "steps" + path: "/BuildComponentAttachment/changeset" + operation: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "attachComponentOrder" + embeddedHotelOrderEvents: + type: { blueId: "Fjbu3QpnUaTruDTcTidETCX2N5STyv7KYxT42PCzGHxm" } + childPath: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "/embeddedDocs/hotelOrder" + embeddedRestaurantOrderEvents: + type: { blueId: "Fjbu3QpnUaTruDTcTidETCX2N5STyv7KYxT42PCzGHxm" } + childPath: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "/embeddedDocs/restaurantOrder" + processEmbeddedComponentOrders: + type: { blueId: "Hu4XkfvyXLSdfFNUwuXebEu3oJeWcMyhBTcRV9AQyKPC" } + paths: + items: + - type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "/embeddedDocs/hotelOrder" + - type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "/embeddedDocs/restaurantOrder" + completeWhenOrdersConfirmedFromHotelEvent: + type: { blueId: "7X3LkN54Yp88JgZbppPhP6hM3Jqiqv8Z2i4kS7phXtQe" } + channel: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "embeddedHotelOrderEvents" + event: + type: { blueId: "5Wz4G9qcnBJnntYRkz4dgLK5bSuoMpYJZj4j5M59z4we" } + kind: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "Order Confirmed" + steps: + items: + - name: "BuildCompletion" + type: { blueId: "ExZxT61PSpWHpEAtP2WKMXXqxEYN7Z13j7Zv36Dp99kS" } + code: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "const hotel = document('/embeddedDocs/hotelOrder/confirmation/status'); const restaurant = document('/embeddedDocs/restaurantOrder/confirmation/status'); if (hotel \u0021== 'confirmed' || restaurant \u0021== 'confirmed' || document('/completionRequested')) return { changeset: [], events: [] }; return { changeset: [{ op: 'replace', path: '/completionRequested', val: true }], events: [{ type: 'PayNote/Complete Payment Requested', amount: 100000 }] };" + - name: "ApplyCompletionFlag" + type: { blueId: "FtHZJzH4hqAoGxFBjsmy1svfT4BwEBB4aHpFSZycZLLa" } + changeset: + $binding: + name: "steps" + path: "/BuildCompletion/changeset" + completeWhenOrdersConfirmedFromRestaurantEvent: + type: { blueId: "7X3LkN54Yp88JgZbppPhP6hM3Jqiqv8Z2i4kS7phXtQe" } + channel: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "embeddedRestaurantOrderEvents" + event: + type: { blueId: "5Wz4G9qcnBJnntYRkz4dgLK5bSuoMpYJZj4j5M59z4we" } + kind: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "Order Confirmed" + steps: + items: + - name: "BuildCompletion" + type: { blueId: "ExZxT61PSpWHpEAtP2WKMXXqxEYN7Z13j7Zv36Dp99kS" } + code: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "const hotel = document('/embeddedDocs/hotelOrder/confirmation/status'); const restaurant = document('/embeddedDocs/restaurantOrder/confirmation/status'); if (hotel \u0021== 'confirmed' || restaurant \u0021== 'confirmed' || document('/completionRequested')) return { changeset: [], events: [] }; return { changeset: [{ op: 'replace', path: '/completionRequested', val: true }], events: [{ type: 'PayNote/Complete Payment Requested', amount: 100000 }] };" + - name: "ApplyCompletionFlag" + type: { blueId: "FtHZJzH4hqAoGxFBjsmy1svfT4BwEBB4aHpFSZycZLLa" } + changeset: + $binding: + name: "steps" + path: "/BuildCompletion/changeset" + initialized: + type: "Processing Initialized Marker" + documentId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "CxSx6ELb64NzbBE5pw5dYpJdQz7JMdtYnLAT25QXuuNa" + checkpoint: + type: { blueId: "B7YQeYdQzUNuzaDQ4tNTd2iJqgd4YnVQkgz4QgymDWWU" } + lastEvents: + guarantorChannel: + type: { blueId: "F3mQaGQ1B48yMedKZojFTxeKxtee4xU66QBbiyEMvGeZ" } + actor: + type: { blueId: "5GB8C22LsZGR3kkEmP5j5Zye7SR173ojzzUK99tUcoP" } + accountId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "0" + message: + type: { blueId: "HM4Ku4LFcjC5MxnhPMRwQ8w3BbHmJKKZfHTTzsd4jbJq" } + operation: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "recordTransactionInitiated" + request: + type: { blueId: "14UHCXtf9XLpi3Z3n4xbo1dmXRzfXnDEH23iVaechxzh" } + initiatedAmount: + type: { blueId: "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1" } + value: 100000 + providerReference: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "harness:customer-paynote-a" + railType: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "fake-payment-rail" + timeline: + timelineId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "admin-timeline" + timestamp: + type: { blueId: "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1" } + value: 1700000000000 + lastSignatures: + guarantorChannel: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "9SJyYbjfPUCxAL26f6GQdriqXKuDZnXJhpPVezN5mMjK" + currency: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "USD" + payNoteInitialStateDescription: + details: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "This PayNote secures the customer's package payment to Boutique Travel Agency. The payment is completed only after both included merchant orders are confirmed: Hotel Aurora confirms the weekend stay order and Restaurant Lumi confirms the wine dinner order. Once both confirmations are present, the package payment is completed and the package becomes ready to use." + summary: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "Payment for the Weekend Stay + Wine Dinner package." + status: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "Initiated" + transactionDetails: + description: | + Payload for the operation. Shape MUST match the target Operation’s `request` contract (scalars or structured nodes). + railType: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "fake-payment-rail" + inResponseTo: + type: + name: "Correlation" + description: "A structured reference linking this response back to the original action and trigger." + requestId: + description: "The 'requestId' from the specific Request event this is a response to." + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + incomingEvent: + description: "An event which initiated the entire workflow. Normally just blueId of it." + attachmentPoint: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + initiatedAmount: + type: { blueId: "5WNMiV9Knz63B4dVY5JtMyh3FB4FSGqv7ceScvuapdE1" } + value: 100000 + providerReference: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "harness:customer-paynote-a" + state: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "not_started" + context: + scenario: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "reseller-weekend-package" + paymentKind: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customer_package_purchase" + packageOrderDocumentId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "zzimVxhnKLL5SwMkS9kmF8p5g7pyxPWBu664HxGbszB" + packagePayNoteSessionId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customer-paynote-a" + packagePayNoteDocumentId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customer-paynote-doc-a" + completionRequested: + type: { blueId: "4EzhSubEimSQD3zrYHRtobfPPWntUuhEz8YcdxHsi12u" } + value: false + targetSessionId: + type: { blueId: "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC" } + value: "customer-paynote-a" diff --git a/src/test/resources/processor-delay/paynote-resale-reduced-bex.yaml b/src/test/resources/processor-delay/paynote-resale-reduced-bex.yaml new file mode 100644 index 0000000..5b67d1e --- /dev/null +++ b/src/test/resources/processor-delay/paynote-resale-reduced-bex.yaml @@ -0,0 +1,765 @@ +name: Reduced Package Fulfillment Resale Flow +status: active +investorChannel: investorChannel +componentOrderRefsBySessionId: + hotel-order-session-a: + packageOrderSessionId: '' + component: '' + restaurant-order-session-a: + packageOrderSessionId: '' + component: '' +resaleOrderRequests: + hotel-request-a: + status: requested + agreementSessionId: hotel-agreement-session + kind: hotel + packageOrderSessionId: package-order-a + orderSessionId: '' + restaurant-request-a: + status: requested + agreementSessionId: restaurant-agreement-session + kind: restaurant + packageOrderSessionId: package-order-a + orderSessionId: '' +orders: + package-order-a: + packageOrder: + observed: true + confirmed: true + sessionId: package-order-a + documentId: package-order-doc-a + subscriptionId: package-linked:package-order-a + customerAccountId: customer-a-uid + customerPayment: + tokenRequestId: reseller-weekend-package-customer-token:package-order-a + tokenRequested: true + tokenAttached: true + customerPayNote: + sessionId: customer-paynote-a + snapshotRequestId: snapshot:customer-paynote:customer-paynote-a + subscriptionId: package-linked:customer-paynote-a + attachedToPackageOrder: true + secured: false + securedAmount: 0 + completed: false + hotelOrder: + sessionId: '' + documentId: '' + resaleRequestId: hotel-request-a + resaleRequested: true + resalePlaced: false + snapshotRequestId: '' + subscriptionId: '' + attachedToPackageOrder: false + attachedToPayNote: false + merchantPaymentInitiated: false + confirmed: false + restaurantOrder: + sessionId: '' + documentId: '' + resaleRequestId: restaurant-request-a + resaleRequested: true + resalePlaced: false + snapshotRequestId: '' + subscriptionId: '' + attachedToPackageOrder: false + attachedToPayNote: false + merchantPaymentInitiated: false + confirmed: false +state: + grantsReady: true +contracts: + hotelParticipantChannel: + type: Coordination/Timeline Channel + timelineId: hotel-participant + restaurantParticipantChannel: + type: Coordination/Timeline Channel + timelineId: restaurant-participant + triggeredEventChannel: + type: Triggered Event Channel + hotelResaleOrderPlaced: + type: Coordination/Operation + channel: hotelParticipantChannel + restaurantResaleOrderPlaced: + type: Coordination/Operation + channel: restaurantParticipantChannel + hotelResaleOrderPlacedImpl: + type: Coordination/Sequential Workflow Operation + operation: hotelResaleOrderPlaced + steps: + - name: ForwardHotelResaleOrderPlaced + type: Coordination/Compute + do: + - $appendEvent: + $binding: + name: event + path: /message/request + - $return: + events: + $events: true + restaurantResaleOrderPlacedImpl: + type: Coordination/Sequential Workflow Operation + operation: restaurantResaleOrderPlaced + steps: + - name: ForwardRestaurantResaleOrderPlaced + type: Coordination/Compute + do: + - $appendEvent: + $binding: + name: event + path: /message/request + - $return: + events: + $events: true + processPackageHotelResaleOrderPlaced: + type: Coordination/Sequential Workflow + channel: triggeredEventChannel + event: + type: Sample/Subscription Update + subscriptionId: hotel-resale-agreement + targetSessionId: hotel-agreement-session + update: + kind: Resale Order Placed + steps: + - name: ProcessPackageHotelResaleOrderPlaced + type: Coordination/Compute + definition: packageFulfillmentComputeDefinition + entry: processHotelResaleOrderPlaced + emitEvents: true + returnResult: true + processPackageRestaurantResaleOrderPlaced: + type: Coordination/Sequential Workflow + channel: triggeredEventChannel + event: + type: Sample/Subscription Update + subscriptionId: restaurant-resale-agreement + targetSessionId: restaurant-agreement-session + update: + kind: Resale Order Placed + steps: + - name: ProcessPackageRestaurantResaleOrderPlaced + type: Coordination/Compute + definition: packageFulfillmentComputeDefinition + entry: processRestaurantResaleOrderPlaced + emitEvents: true + returnResult: true + packageFulfillmentComputeDefinition: + type: Coordination/Compute Definition + constants: + packageLinkedSubscriptionPrefix: 'package-linked:' + agreementLinkedSubscriptionPrefix: 'agreement-linked:' + functions: + emptyComponentOrderState: + do: + - $return: + sessionId: '' + documentId: '' + resaleRequestId: '' + resaleRequested: false + resalePlaced: false + snapshotRequestId: '' + subscriptionId: '' + attachedToPackageOrder: false + attachedToPayNote: false + merchantPaymentInitiated: false + confirmed: false + defaultOrderState: + args: + sessionId: + type: Text + do: + - $let: + name: sessionId + expr: + $text: + $var: sessionId + - $return: + packageOrder: + sessionId: + $var: sessionId + documentId: '' + customerAccountId: '' + subscriptionId: + $concat: + - $const: packageLinkedSubscriptionPrefix + - $var: sessionId + observed: false + confirmed: false + customerPayment: + tokenRequestId: + $concat: + - 'reseller-weekend-package-customer-token:' + - $var: sessionId + tokenRequested: false + tokenAttached: false + customerPayNote: + sessionId: '' + snapshotRequestId: '' + subscriptionId: '' + attachedToPackageOrder: false + secured: false + securedAmount: 0 + completed: false + hotelOrder: + $call: + function: emptyComponentOrderState + args: {} + restaurantOrder: + $call: + function: emptyComponentOrderState + args: {} + appendChangeIfChanged: + args: + path: + type: Text + val: + description: Value to compare and append. + do: + - $let: + name: pathText + expr: + $text: + $var: path + - $let: + name: current + expr: + $resultValue: + path: + $var: pathText + - $if: + cond: + $ne: + - $var: current + - $var: val + then: + - $appendChange: + op: replace + path: + $var: pathText + val: + $var: val + - $return: {} + ensureOrderLedger: + args: + sessionId: + type: Text + do: + - $let: + name: sessionId + expr: + $text: + $var: sessionId + - $if: + cond: + $empty: + $var: sessionId + then: + - $return: false + - $let: + name: pkg + expr: + $resultValue: + path: + $concat: + - /orders/ + - $var: sessionId + - /packageOrder + - $if: + cond: + $empty: + $object: + $var: pkg + then: + - $appendChange: + op: add + path: + $concat: + - /orders/ + - $var: sessionId + val: + $call: + function: defaultOrderState + args: + sessionId: + $var: sessionId + - $return: {} + orderObjectFieldRelativePath: + args: + field: + type: Text + kind: + type: Text + do: + - $return: + $pointerGet: + object: + componentOrderSessions: + hotel: /hotelOrder/sessionId + restaurant: /restaurantOrder/sessionId + componentOrderDocumentIds: + hotel: /hotelOrder/documentId + restaurant: /restaurantOrder/documentId + componentOrderAttached: + hotel: /hotelOrder/attachedToPackageOrder + restaurant: /restaurantOrder/attachedToPackageOrder + componentOrderAttachedToPayNote: + hotel: /hotelOrder/attachedToPayNote + restaurant: /restaurantOrder/attachedToPayNote + componentOrderConfirmed: + hotel: /hotelOrder/confirmed + restaurant: /restaurantOrder/confirmed + merchantPaymentInitiated: + hotel: /hotelOrder/merchantPaymentInitiated + restaurant: /restaurantOrder/merchantPaymentInitiated + resaleOrderPlaced: + hotel: /hotelOrder/resalePlaced + restaurant: /restaurantOrder/resalePlaced + resaleOrderRequested: + hotel: /hotelOrder/resaleRequested + restaurant: /restaurantOrder/resaleRequested + resaleOrderRequestIds: + hotel: /hotelOrder/resaleRequestId + restaurant: /restaurantOrder/resaleRequestId + componentSnapshotRequestIds: + hotel: /hotelOrder/snapshotRequestId + restaurant: /restaurantOrder/snapshotRequestId + componentSubscriptionIds: + hotel: /hotelOrder/subscriptionId + restaurant: /restaurantOrder/subscriptionId + path: + $concat: + - / + - $text: + $var: field + - / + - $text: + $var: kind + default: '' + setOrderPath: + args: + sessionId: + type: Text + relativePath: + type: Text + val: + description: Value to write. + do: + - $let: + name: sessionId + expr: + $text: + $var: sessionId + - $if: + cond: + $empty: + $var: sessionId + then: + - $return: false + - $call: + function: ensureOrderLedger + args: + sessionId: + $var: sessionId + - $let: + name: pathText + expr: + $concat: + - /orders/ + - $var: sessionId + - $text: + $var: relativePath + - $if: + cond: + $ne: + - $resultValue: + path: + $var: pathText + - $var: val + then: + - $appendChange: + op: add + path: + $var: pathText + val: + $var: val + - $return: {} + mergeOrderObjectField: + args: + sessionId: + type: Text + key: + type: Text + patch: + description: Object patch keyed by component kind. + do: + - $let: + name: sessionId + expr: + $text: + $var: sessionId + - $forEach: + in: + $entries: + $object: + $var: patch + item: entry + do: + - $let: + name: kind + expr: + $text: + $pointerGet: + object: + $var: entry + path: /key + default: '' + - $let: + name: relativePath + expr: + $call: + function: orderObjectFieldRelativePath + args: + field: + $var: key + kind: + $var: kind + - $if: + cond: + $not: + $empty: + $var: relativePath + then: + - $call: + function: setOrderPath + args: + sessionId: + $var: sessionId + relativePath: + $var: relativePath + val: + $pointerGet: + object: + $var: entry + path: /val + - $return: {} + requestComponentOrderDelivery: + args: + packageOrderSessionId: + type: Text + agreementKind: + type: Text + orderSessionId: + type: Text + do: + - $let: + name: packageOrderSessionId + expr: + $text: + $var: packageOrderSessionId + - $let: + name: agreementKind + expr: + $text: + $var: agreementKind + - $let: + name: orderSessionId + expr: + $text: + $var: orderSessionId + - $if: + cond: + $or: + - $not: + $truthy: + $var: packageOrderSessionId + - $not: + $truthy: + $var: agreementKind + - $not: + $truthy: + $var: orderSessionId + then: + - $return: false + - $let: + name: snapshotRequestId + expr: + $concat: + - 'snapshot:component:' + - $var: agreementKind + - ':' + - $var: orderSessionId + - $let: + name: subscriptionId + expr: + $concat: + - $const: agreementLinkedSubscriptionPrefix + - $var: agreementKind + - ':' + - $var: orderSessionId + - $let: + name: componentPathPrefix + expr: + $choose: + cond: + $eq: + - $var: agreementKind + - hotel + then: /hotelOrder + else: /restaurantOrder + - $if: + cond: + $empty: + $text: + $resultValue: + path: + $concat: + - /orders/ + - $var: packageOrderSessionId + - $var: componentPathPrefix + - /snapshotRequestId + then: + - $call: + function: setOrderPath + args: + sessionId: + $var: packageOrderSessionId + relativePath: + $call: + function: orderObjectFieldRelativePath + args: + field: componentSnapshotRequestIds + kind: + $var: agreementKind + val: + $var: snapshotRequestId + - $appendEvent: + $pointerSet: + object: + onBehalfOf: investorChannel + requestId: + $var: snapshotRequestId + sourceSessionId: + $var: orderSessionId + path: /type + val: Sample/Document Initial Snapshot Requested + - $if: + cond: + $empty: + $text: + $resultValue: + path: + $concat: + - /orders/ + - $var: packageOrderSessionId + - $var: componentPathPrefix + - /subscriptionId + then: + - $call: + function: setOrderPath + args: + sessionId: + $var: packageOrderSessionId + relativePath: + $call: + function: orderObjectFieldRelativePath + args: + field: componentSubscriptionIds + kind: + $var: agreementKind + val: + $var: subscriptionId + - $appendEvent: + $pointerSet: + object: + onBehalfOf: investorChannel + targetSessionId: + $var: orderSessionId + subscription: + id: + $var: subscriptionId + events: + - type: Coordination/Event + kind: Payment Token Attached + - type: Coordination/Event + kind: Order Confirmed + path: /type + val: Sample/Subscribe to Session Requested + - $return: {} + recordPlacedResaleOrder: + args: + agreementKind: + type: Text + responseRequestId: + type: Text + orderSessionId: + type: Text + do: + - $let: + name: agreementKind + expr: + $text: + $var: agreementKind + - $let: + name: responseRequestId + expr: + $text: + $var: responseRequestId + - $let: + name: orderSessionId + expr: + $text: + $var: orderSessionId + - $if: + cond: + $or: + - $not: + $truthy: + $var: agreementKind + - $not: + $truthy: + $var: responseRequestId + - $not: + $truthy: + $var: orderSessionId + then: + - $return: false + - $let: + name: existingRequest + expr: + $object: + $resultValue: + path: + $concat: + - /resaleOrderRequests/ + - $var: responseRequestId + - $let: + name: nextRequest1 + expr: + $merge: + - $var: existingRequest + - kind: + $coalesce: + - $text: + $pointerGet: + object: + $var: existingRequest + path: /kind + default: '' + - $var: agreementKind + orderSessionId: + $var: orderSessionId + status: placed + - $if: + cond: + $ne: + - $var: existingRequest + - $var: nextRequest1 + then: + - $appendChange: + op: add + path: + $concat: + - /resaleOrderRequests/ + - $var: responseRequestId + val: + $var: nextRequest1 + - $let: + name: packageOrderSessionId + expr: + $text: + $pointerGet: + object: + $var: nextRequest1 + path: /packageOrderSessionId + default: '' + - $if: + cond: + $not: + $empty: + $var: packageOrderSessionId + then: + - $call: + function: setOrderPath + args: + sessionId: + $var: packageOrderSessionId + relativePath: + $call: + function: orderObjectFieldRelativePath + args: + field: resaleOrderPlaced + kind: + $var: agreementKind + val: true + - $call: + function: setOrderPath + args: + sessionId: + $var: packageOrderSessionId + relativePath: + $call: + function: orderObjectFieldRelativePath + args: + field: componentOrderSessions + kind: + $var: agreementKind + val: + $var: orderSessionId + - $call: + function: appendChangeIfChanged + args: + path: + $concat: + - /componentOrderRefsBySessionId/ + - $var: orderSessionId + val: + packageOrderSessionId: + $var: packageOrderSessionId + component: + $concat: + - $var: agreementKind + - Order + - $call: + function: requestComponentOrderDelivery + args: + packageOrderSessionId: + $var: packageOrderSessionId + agreementKind: + $var: agreementKind + orderSessionId: + $var: orderSessionId + - $return: {} + processHotelResaleOrderPlaced: + do: + - $call: + function: recordPlacedResaleOrder + args: + agreementKind: hotel + responseRequestId: + $text: + $event: /update/inResponseTo/requestId + orderSessionId: + $text: + $event: /update/orderSessionId + - $return: + changeset: + $changeset: true + events: + $events: true + processRestaurantResaleOrderPlaced: + do: + - $call: + function: recordPlacedResaleOrder + args: + agreementKind: restaurant + responseRequestId: + $text: + $event: /update/inResponseTo/requestId + orderSessionId: + $text: + $event: /update/orderSessionId + - $return: + changeset: + $changeset: true + events: + $events: true